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
+1
View File
@@ -0,0 +1 @@
node_modules
+185
View File
@@ -0,0 +1,185 @@
const 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 corsHeaders(extra = {}) {
return new Headers({
'access-control-allow-origin': '*',
'cache-control': 'no-store',
...extra,
});
}
function jsonResponse(status, obj) {
return new Response(JSON.stringify(obj), {
status,
headers: corsHeaders({ 'content-type': 'application/json; charset=utf-8' }),
});
}
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 (DISALLOWED.has(name.toLowerCase())) continue;
out[name] = value;
}
return out;
}
function proxyTarget(target, headers, token) {
const query = new URLSearchParams({
url: target,
h: JSON.stringify(headers || {}),
});
if (token) query.set('token', token);
return `media?${query.toString()}`;
}
function rewriteM3u8(text, baseUrl, headers, token) {
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, token)}"`;
} catch {
return match;
}
});
}
try {
return proxyTarget(new URL(trimmed, baseUrl).toString(), headers, token);
} catch {
return line;
}
})
.join('\n');
}
async function proxy(request, url, env) {
const target = (url.searchParams.get('url') || '').trim();
const headers = parseProxyHeaders(url.searchParams.get('h'));
const token = url.searchParams.get('token') || '';
let parsed;
try {
parsed = new URL(target);
} catch {
return jsonResponse(400, { error: 'missing or invalid ?url=' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return jsonResponse(400, { error: 'proxy only supports http/https' });
}
const upstreamHeaders = new Headers();
for (const [name, value] of Object.entries(headers)) {
upstreamHeaders.set(name, value);
}
const range = request.headers.get('range');
if (range) upstreamHeaders.set('range', range);
let upstream;
try {
upstream = await fetch(parsed.toString(), {
headers: upstreamHeaders,
redirect: 'follow',
});
} catch (e) {
return jsonResponse(502, { error: `proxy fetch failed: ${e.message}` });
}
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, token);
return new Response(body, {
status: upstream.status,
headers: corsHeaders({ 'content-type': 'application/vnd.apple.mpegurl; charset=utf-8' }),
});
}
return new Response(text, {
status: upstream.status,
headers: corsHeaders({ 'content-type': contentType || 'text/plain; charset=utf-8' }),
});
} catch (e) {
return jsonResponse(502, { error: `proxy playlist failed: ${e.message}` });
}
}
const out = corsHeaders();
for (const name of ['content-type', 'content-range', 'accept-ranges']) {
const value = upstream.headers.get(name);
if (value) out.set(name, value);
}
if (upstream.status === 206) {
const value = upstream.headers.get('content-length');
if (value) out.set('content-length', value);
}
return new Response(upstream.body || null, {
status: upstream.status,
headers: out,
});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: corsHeaders({
'access-control-allow-methods': 'GET, OPTIONS',
'access-control-allow-headers': '*',
}),
});
}
if (request.method !== 'GET') {
return jsonResponse(405, { error: 'method not allowed, use GET' });
}
if (env.PROXY_TOKEN) {
const token = url.searchParams.get('token') || request.headers.get('x-proxy-token') || '';
if (token !== env.PROXY_TOKEN) {
return jsonResponse(403, { error: 'unauthorized' });
}
}
return await proxy(request, url, env);
},
};
+13
View File
@@ -0,0 +1,13 @@
{
"tmdbKey": "ac014b130a8e6344c91dff4e68b18d47",
"enabledProviders": [
"cineby"
],
"timeoutMs": 30000,
"filters": {
"minQuality": "any",
"audioCodec": "any",
"subtitles": "any",
"subtitleLang": ""
}
}
+9
View File
@@ -0,0 +1,9 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
};
outputs = { nixpkgs, ... }: {
};
}
+40
View File
@@ -0,0 +1,40 @@
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
const DEFAULTS = {
tmdbKey: '',
enabledProviders: ['videasy', 'vidfast'],
timeoutMs: 30000,
apiHost: '0.0.0.0',
apiPort: 8789,
filters: {
minQuality: 'any',
audioCodec: 'any',
subtitles: 'any',
subtitleLang: '',
},
};
function loadConfig() {
let cfg = { ...DEFAULTS, filters: { ...DEFAULTS.filters } };
if (fs.existsSync(CONFIG_PATH)) {
const saved = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
cfg = { ...cfg, ...saved, filters: { ...cfg.filters, ...(saved.filters || {}) } };
} else {
saveConfig(cfg);
}
if (!cfg.tmdbKey && process.env.TMDB_API_KEY) cfg.tmdbKey = process.env.TMDB_API_KEY;
if (!cfg.tmdbKey) {
console.error('No TMDB API key found. Set "tmdbKey" in config.json or the TMDB_API_KEY env var.');
process.exit(1);
}
return cfg;
}
function saveConfig(cfg) {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
}
module.exports = { loadConfig, saveConfig, CONFIG_PATH };
+37
View File
@@ -0,0 +1,37 @@
const QUALITY_MIN = { any: 0, '480': 480, '720': 720, '1080': 1080, '2160': 2160 };
const AUDIO_OPTIONS = ['any', 'aac', 'ac3', 'dts', 'opus', 'mp3', 'flac', 'multi'];
const SUBTITLE_OPTIONS = ['any', 'required', 'lang'];
function langCode(lang) {
return String(lang || '').toLowerCase().replace(/[^a-z]/g, '').slice(0, 2);
}
function hasSubtitleLang(streams_subtitles, wanted) {
const want = langCode(wanted);
if (!want) return true;
return streams_subtitles.some((s) => {
const code = langCode(s.language) || langCode(s.name);
return code === want;
});
}
function applyFilters(streams, filters) {
const minPixels = QUALITY_MIN[filters.minQuality] ?? 0;
return streams.filter((s) => {
if (minPixels > 0 && (s.pixels == null || s.pixels < minPixels)) return false;
if (filters.audioCodec && filters.audioCodec !== 'any' && s.audio !== filters.audioCodec) return false;
if (filters.subtitles && filters.subtitles !== 'any') {
if (s.subtitles.length === 0) return false;
if (filters.subtitles === 'lang' && filters.subtitleLang && !hasSubtitleLang(s.subtitles, filters.subtitleLang)) return false;
}
return true;
});
}
function rankStreams(streams) {
return streams
.slice()
.sort((a, b) => (b.pixels || 0) - (a.pixels || 0) || a.provider.localeCompare(b.provider));
}
module.exports = { applyFilters, rankStreams, QUALITY_MIN, AUDIO_OPTIONS, SUBTITLE_OPTIONS };
+37
View File
@@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
const PROVIDERS_DIR = path.join(__dirname, '..', 'providers');
function listAll() {
return fs
.readdirSync(PROVIDERS_DIR)
.filter((f) => f.endsWith('.js'))
.map((f) => f.slice(0, -3))
.sort();
}
function loadProviders(names) {
const providers = [];
const loadErrors = {};
for (const name of names) {
const file = path.join(PROVIDERS_DIR, `${name}.js`);
if (!fs.existsSync(file)) {
loadErrors[name] = 'not found';
continue;
}
try {
const mod = require(file);
if (typeof mod.getStreams !== 'function') {
loadErrors[name] = 'does not export getStreams()';
continue;
}
providers.push({ name, getStreams: mod.getStreams });
} catch (err) {
loadErrors[name] = err.message.split('\n')[0];
}
}
return { providers, loadErrors };
}
module.exports = { listAll, loadProviders, PROVIDERS_DIR };
+91
View File
@@ -0,0 +1,91 @@
function qualityToPixels(q) {
if (!q) return null;
const s = String(q).toLowerCase();
if (/2160|4k|uhd/.test(s)) return 2160;
const m = s.match(/(\d{3,4})\s*p/);
if (m) {
const n = parseInt(m[1], 10);
return n >= 1440 ? 2160 : n;
}
if (/1080|full\s?hd|fhd/.test(s)) return 1080;
if (/720/.test(s)) return 720;
if (/480/.test(s)) return 480;
return null;
}
function detectAudio(text) {
const t = ` ${String(text || '').toLowerCase()} `;
if (/\bdts\b|dts-?hd/.test(t)) return 'dts';
if (/e-?ac3|dd\+|ac-?3|dolby digital/.test(t)) return 'ac3';
if (/\baac\b/.test(t)) return 'aac';
if (/\bopus\b/.test(t)) return 'opus';
if (/\bflac\b|lossless/.test(t)) return 'flac';
if (/\bmp3\b/.test(t)) return 'mp3';
if (/dual-?audio|multi-?audio|original audio|multi audio/.test(t)) return 'multi';
return null;
}
function extOf(text) {
const m = String(text || '').toLowerCase().split(/[?#]/)[0].match(/\.([a-z0-9]{2,5})$/);
return m ? m[1] : '';
}
function streamType(url, name) {
const u = String(url || '');
const n = String(name || '').toLowerCase();
if (/^magnet:/i.test(u)) return 'magnet';
if (/m3u8/i.test(u)) return 'm3u8';
const ext = extOf(u) || extOf(n);
if (ext === 'mkv' || /\.mkv\b/.test(n)) return 'mkv';
if (ext === 'mp4' || ext === 'm4v' || ext === 'mov' || /\.mp4\b/.test(n)) return 'mp4';
if (ext === 'avi' || /\.avi\b/.test(n)) return 'avi';
if (ext === 'webm') return 'webm';
if (ext === 'ts' || ext === 'm4a') return 'other';
return ext || 'other';
}
function normalizeSubtitles(subs) {
if (!Array.isArray(subs)) return [];
return subs
.filter((s) => s && typeof s.url === 'string' && s.url.length > 0)
.map((s) => ({
url: s.url,
language: s.language || s.id || s.lang || '',
name: s.name || '',
}));
}
function normalize(raw, providerName) {
const text = [raw.name, raw.title, raw.description].filter(Boolean).join(' ');
const pixels = qualityToPixels(raw.quality) ?? qualityToPixels(`${raw.name || ''} ${raw.title || ''}`);
return {
provider: providerName,
serverName: raw._serverName || '',
name: raw.name || raw.title || 'stream',
title: raw.title || '',
description: raw.description || '',
url: raw.url,
quality: raw.quality || (pixels ? `${pixels}p` : ''),
pixels,
audio: detectAudio(text),
type: streamType(raw.url, raw.name || raw.title),
subtitles: normalizeSubtitles(raw.subtitles),
size: typeof raw.size === 'number' ? raw.size : 0,
headers: raw.headers && typeof raw.headers === 'object' ? raw.headers : {},
is4k: !!raw._is4k || pixels === 2160,
};
}
function formatBytes(n) {
if (!n || n <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)}${units[i]}`;
}
module.exports = { normalize, qualityToPixels, detectAudio, formatBytes };
+55
View File
@@ -0,0 +1,55 @@
function withTimeout(promise, ms, label) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
}
);
});
}
function toStreamArray(raw, providerName) {
let arr = Array.isArray(raw) ? raw : raw && typeof raw === 'object' && raw.url ? [raw] : [];
return arr
.filter((s) => s && typeof s === 'object' && typeof s.url === 'string' && s.url.length > 0)
.map((s) => ({ ...s, _provider: s._provider || providerName }));
}
async function scrapeAll(providers, media, timeoutMs) {
const settled = await Promise.allSettled(
providers.map(async (p) => {
const raw = await withTimeout(
Promise.resolve().then(() =>
p.getStreams(String(media.id), media.type, media.seasonId ?? null, media.episodeId ?? null)
),
timeoutMs,
p.name
);
return toStreamArray(raw, p.name);
})
);
const streams = [];
const errors = {};
settled.forEach((r, i) => {
if (r.status === 'fulfilled') streams.push(...r.value);
else errors[providers[i].name] = (r.reason && r.reason.message) || String(r.reason);
});
const seen = new Set();
const unique = [];
for (const s of streams) {
if (seen.has(s.url)) continue;
seen.add(s.url);
unique.push(s);
}
return { streams: unique, errors };
}
module.exports = { scrapeAll, withTimeout };
+43
View File
@@ -0,0 +1,43 @@
const BASE = 'https://api.themoviedb.org/3';
async function get(pathname, key) {
const sep = pathname.includes('?') ? '&' : '?';
const res = await fetch(`${BASE}${pathname}${sep}api_key=${encodeURIComponent(key)}`);
if (!res.ok) throw new Error(`TMDB HTTP ${res.status} for ${pathname}`);
return res.json();
}
function search(query, key) {
return get(
`/search/multi?query=${encodeURIComponent(query)}&include_adult=false&language=en-US&page=1`,
key
).then((r) =>
(r.results || []).filter((m) => m.media_type === 'movie' || m.media_type === 'tv').slice(0, 15)
);
}
function trending(mediaType, timeWindow, key) {
const type = ['movie', 'tv', 'all'].includes(mediaType) ? mediaType : 'all';
const window_ = timeWindow === 'day' ? 'day' : 'week';
return get(`/trending/${type}/${window_}?language=en-US`, key).then((r) =>
(r.results || []).slice(0, 20)
);
}
function getSeasons(tvId, key) {
return get(`/tv/${tvId}?language=en-US`, key).then((r) =>
(r.seasons || []).filter((s) => s.season_number > 0 && (s.episode_count || 0) > 0)
);
}
function getEpisodes(tvId, seasonNumber, key) {
return get(`/tv/${tvId}/season/${seasonNumber}?language=en-US`, key).then((r) =>
(r.episodes || []).slice().sort((a, b) => a.episode_number - b.episode_number)
);
}
function details(type, id, key) {
return get(`/${type}/${id}?language=en-US&append_to_response=images`, key);
}
module.exports = { search, trending, getSeasons, getEpisodes, details };
+989
View File
@@ -0,0 +1,989 @@
{
"name": "streamreverse",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "streamreverse",
"version": "0.1.0",
"dependencies": {
"cheerio-without-node-native": "^0.20.2",
"crypto-js": "^4.2.0"
}
},
"node_modules/abab": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz",
"integrity": "sha512-I+Wi+qiE2kUXyrRhNsWv6XsjUTBJjSoVSctKNBfLG5zG/Xe7Rjbxf13+vqYHNTwHaFU+FtSlVxOCTiMEVtPv0A==",
"deprecated": "Use your platform's native atob() and btoa() methods instead",
"license": "ISC",
"optional": true
},
"node_modules/acorn": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz",
"integrity": "sha512-pXK8ez/pVjqFdAgBkF1YPVRacuLQ9EXBKaKWaeh58WNfMkCmZhOZzu+NtKSPD5PHmCCHheQ5cD29qM1K4QTxIg==",
"license": "MIT",
"optional": true,
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-globals": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-1.0.9.tgz",
"integrity": "sha512-j3/4pkfih8W4NK22gxVSXcEonTpAHOHh0hu5BoZrKcOsW/4oBPxTi4Yk3SAj+FhC1f3+bRTkXdm4019gw1vg9g==",
"license": "MIT",
"optional": true,
"dependencies": {
"acorn": "^2.1.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT",
"optional": true
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": "*"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
"license": "MIT",
"optional": true
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0",
"optional": true
},
"node_modules/cheerio-without-node-native": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/cheerio-without-node-native/-/cheerio-without-node-native-0.20.2.tgz",
"integrity": "sha512-TiXlwWtMZIYU9ujMWtvMiKIVw/aZcQ8G6kxvn53zr0fU12kXIXSs0ICH05LGzjAsmQxdoC/WBuN882MyTbLNrw==",
"license": "MIT",
"dependencies": {
"css-select": "~1.2.0",
"dom-serializer": "~0.1.0",
"entities": "~1.1.1",
"htmlparser2-without-node-native": "^3.9.0",
"lodash": "^4.1.0"
},
"engines": {
"node": ">= 0.6"
},
"optionalDependencies": {
"jsdom": "^7.0.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"optional": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.",
"license": "MIT"
},
"node_modules/css-select": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
"integrity": "sha512-dUQOBoqdR7QwV90WysXPLXG5LO7nhYBgiWVfxF80DKPF8zx1t/pUd2FYy73emg3zrjtM6dzmYgbHKfV2rxiHQA==",
"license": "BSD-like",
"dependencies": {
"boolbase": "~1.0.0",
"css-what": "2.1",
"domutils": "1.5.1",
"nth-check": "~1.0.1"
}
},
"node_modules/css-what": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz",
"integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==",
"license": "BSD-2-Clause",
"engines": {
"node": "*"
}
},
"node_modules/cssom": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"license": "MIT",
"optional": true
},
"node_modules/cssstyle": {
"version": "0.2.37",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz",
"integrity": "sha512-FUpKc+1FNBsHUr9IsfSGCovr8VuGOiiuzlgCyppKBjJi2jYTOFLN3oiiNRMIvYqbFzF38mqKj4BgcevzU5/kIA==",
"license": "MIT",
"optional": true,
"dependencies": {
"cssom": "0.3.x"
}
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"license": "MIT",
"optional": true,
"dependencies": {
"assert-plus": "^1.0.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT",
"optional": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dom-serializer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz",
"integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==",
"license": "MIT",
"dependencies": {
"domelementtype": "^1.3.0",
"entities": "^1.1.1"
}
},
"node_modules/domelementtype": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "1"
}
},
"node_modules/domutils": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
"integrity": "sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==",
"dependencies": {
"dom-serializer": "0",
"domelementtype": "1"
}
},
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"license": "MIT",
"optional": true,
"dependencies": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/entities": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
"license": "BSD-2-Clause"
},
"node_modules/escodegen": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz",
"integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==",
"license": "BSD-2-Clause",
"optional": true,
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^4.2.0",
"esutils": "^2.0.2",
"optionator": "^0.8.1"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=4.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/estraverse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"optional": true,
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/eventemitter2": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-1.0.5.tgz",
"integrity": "sha512-EUFhWUYzqqBZlzBMI+dPU8rnKXfQZEUnitnccQuEIAnvWFHCpt3+4fts2+4dpxLtlsiseVXCMFg37KjYChSxpg==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT",
"optional": true
},
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"optional": true
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"optional": true
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT",
"optional": true
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT",
"optional": true
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": "*"
}
},
"node_modules/form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"license": "MIT",
"optional": true,
"dependencies": {
"assert-plus": "^1.0.0"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=4"
}
},
"node_modules/har-validator": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
"deprecated": "this library is no longer supported",
"license": "MIT",
"optional": true,
"dependencies": {
"ajv": "^6.12.3",
"har-schema": "^2.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/htmlparser2-without-node-native": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/htmlparser2-without-node-native/-/htmlparser2-without-node-native-3.9.2.tgz",
"integrity": "sha512-+FplQXqmY5fRx6vCIp2P5urWaoBCpTNJMXnKP/6mNCcyb+AZWWJzA8D03peXfozlxDL+vpgLK5dJblqEgu8j6A==",
"license": "MIT",
"dependencies": {
"domelementtype": "^1.3.0",
"domhandler": "^2.3.0",
"domutils": "^1.5.1",
"entities": "^1.1.1",
"eventemitter2": "^1.0.0",
"inherits": "^2.0.1",
"readable-stream": "^2.0.2"
}
},
"node_modules/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
"sshpk": "^1.7.0"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.3.7"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT",
"optional": true
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
"license": "MIT",
"optional": true
},
"node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"license": "MIT",
"optional": true
},
"node_modules/jsdom": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-7.2.2.tgz",
"integrity": "sha512-kYeYuos/pYp0V/V8VAoGnUc0va0UZjTjwCsldBFZNBrOi9Q5kUXrvsw6W5/lQllB7hKXBARC4HRk1Sfk4dPFtA==",
"license": "MIT",
"optional": true,
"dependencies": {
"abab": "^1.0.0",
"acorn": "^2.4.0",
"acorn-globals": "^1.0.4",
"cssom": ">= 0.3.0 < 0.4.0",
"cssstyle": ">= 0.2.29 < 0.3.0",
"escodegen": "^1.6.1",
"nwmatcher": ">= 1.3.7 < 2.0.0",
"parse5": "^1.5.1",
"request": "^2.55.0",
"sax": "^1.1.4",
"symbol-tree": ">= 3.1.0 < 4.0.0",
"tough-cookie": "^2.2.0",
"webidl-conversions": "^2.0.0",
"whatwg-url-compat": "~0.6.5",
"xml-name-validator": ">= 2.0.1 < 3.0.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)",
"optional": true
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT",
"optional": true
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC",
"optional": true
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
"license": "MIT",
"optional": true,
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.4.0",
"verror": "1.10.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/levn": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
"integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
"license": "MIT",
"optional": true,
"dependencies": {
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"optional": true,
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nth-check": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz",
"integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "~1.0.0"
}
},
"node_modules/nwmatcher": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz",
"integrity": "sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ==",
"license": "MIT",
"optional": true
},
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": "*"
}
},
"node_modules/optionator": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
"integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
"license": "MIT",
"optional": true,
"dependencies": {
"deep-is": "~0.1.3",
"fast-levenshtein": "~2.0.6",
"levn": "~0.3.0",
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2",
"word-wrap": "~1.2.3"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/parse5": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz",
"integrity": "sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==",
"optional": true
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
"integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
"optional": true,
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"optional": true,
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.5.5",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz",
"integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.6"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.3.2",
"har-validator": "~5.1.3",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~2.5.0",
"tunnel-agent": "^0.6.0",
"uuid": "^3.3.2"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT",
"optional": true
},
"node_modules/sax": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT",
"optional": true
},
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense",
"optional": true
},
"node_modules/type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
"integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"prelude-ls": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"optional": true,
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"optional": true,
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"optional": true,
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"node_modules/verror/node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"license": "MIT",
"optional": true
},
"node_modules/webidl-conversions": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-2.0.1.tgz",
"integrity": "sha512-OZ7I/f0sM+T28T2/OXinNGfmvjm3KKptdyQy8NPRZyLfYBn+9vt72Bfr+uQaE9OvWyxJjQ5kHFygH2wOTUb76g==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/whatwg-url-compat": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/whatwg-url-compat/-/whatwg-url-compat-0.6.5.tgz",
"integrity": "sha512-vbg5+JVNwGtHRI3GheZGWrcUlxF9BXHbA80dLa+2XqJjlV/BK6upoi2j8dIRW9FGPUUyaMm7Hf1pTexHnsk85g==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.1"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/xml-name-validator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-2.0.1.tgz",
"integrity": "sha512-jRKe/iQYMyVJpzPH+3HL97Lgu5HrCfii+qSo+TfjKHtOnvbnvdVfMYrn9Q34YV81M2e5sviJlI6Ko9y+nByzvA==",
"license": "WTFPL",
"optional": true
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "streamreverse",
"version": "0.1.0",
"private": true,
"description": "Multi-provider stream finder with TMDB search and REST API",
"main": "rest/server.js",
"scripts": {
"start": "node rest/server.js",
"api": "node rest/server.js"
},
"dependencies": {
"cheerio-without-node-native": "^0.20.2",
"crypto-js": "^4.2.0"
}
}
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+67
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+494
View File
@@ -0,0 +1,494 @@
// OpenAPI 3.0 spec for the streamreverse REST API.
// Served at GET /api.json and rendered by Swagger UI at GET /.
const spec = {
openapi: '3.0.3',
info: {
title: 'bug.tv api',
version: '1.0.0'
},
tags: [
{ name: 'meta', description: 'service and provider metadata' },
{ name: 'search', description: 'TMDB search and episode browsing' },
{ name: 'details', description: 'TMDB media details' },
{ name: 'streams', description: 'm3u8 stream lookup from providers' },
],
paths: {
'/providers': {
get: {
tags: ['meta'],
summary: 'List all providers and which are enabled',
operationId: 'listProviders',
responses: {
200: {
description: 'provider list',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
all: { type: 'array', items: { type: 'string' } },
enabled: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
},
},
},
'/proxy/media': {
get: {
tags: ['meta'],
summary: 'Proxy upstream media or m3u8 playlists with server-side headers',
operationId: 'proxyMedia',
parameters: [
{
name: 'url',
in: 'query',
required: true,
schema: { type: 'string' },
},
{
name: 'h',
in: 'query',
required: false,
description: 'JSON-encoded headers to send to the upstream URL',
schema: { type: 'string' },
},
],
responses: {
200: {
description: 'Proxied upstream response. m3u8 playlist URIs are rewritten to use the proxy.',
},
400: { $ref: '#/components/responses/BadRequest' },
502: { $ref: '#/components/responses/Upstream' },
},
},
},
'/search': {
get: {
tags: ['search'],
summary: 'Search movies + TV via TMDB',
operationId: 'search',
parameters: [
{
name: 'q',
in: 'query',
required: true,
description: 'search text',
schema: { type: 'string' },
example: 'dune',
},
],
responses: {
200: {
description: 'up to 15 results',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
query: { type: 'string' },
count: { type: 'integer' },
results: { type: 'array', items: { $ref: '#/components/schemas/SearchResult' } },
},
},
},
},
},
400: { $ref: '#/components/responses/BadRequest' },
502: { $ref: '#/components/responses/Upstream' },
},
},
},
'/trending': {
get: {
tags: ['search'],
summary: 'Trending movies and TV via TMDB',
operationId: 'getTrending',
parameters: [
{
name: 'type',
in: 'query',
required: false,
schema: { type: 'string', enum: ['all', 'movie', 'tv'], default: 'all' },
},
{
name: 'window',
in: 'query',
required: false,
schema: { type: 'string', enum: ['day', 'week'], default: 'week' },
},
],
responses: {
200: {
description: 'up to 20 trending items',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
type: { type: 'string' },
window: { type: 'string' },
count: { type: 'integer' },
results: { type: 'array', items: { $ref: '#/components/schemas/SearchResult' } },
},
},
},
},
},
400: { $ref: '#/components/responses/BadRequest' },
502: { $ref: '#/components/responses/Upstream' },
},
},
},
'/movie/{id}': {
get: {
tags: ['details'],
summary: 'Movie details (genres, rating, images, description)',
description: 'Passes through the raw TMDB movie object, plus `poster_url`/`backdrop_url` convenience fields.',
operationId: 'getMovieDetails',
parameters: [{ $ref: '#/components/parameters/MovieId' }],
responses: {
200: {
description: 'TMDB movie object',
content: { 'application/json': { schema: { $ref: '#/components/schemas/MediaDetails' } } },
},
404: { $ref: '#/components/responses/NotFound' },
502: { $ref: '#/components/responses/Upstream' },
},
},
},
'/tv/{id}': {
get: {
tags: ['details'],
summary: 'TV show details (genres, rating, images, description)',
description: 'Passes through the raw TMDB tv object, plus `poster_url`/`backdrop_url` convenience fields.',
operationId: 'getTvDetails',
parameters: [{ $ref: '#/components/parameters/TvId' }],
responses: {
200: {
description: 'TMDB tv object',
content: { 'application/json': { schema: { $ref: '#/components/schemas/MediaDetails' } } },
},
404: { $ref: '#/components/responses/NotFound' },
502: { $ref: '#/components/responses/Upstream' },
},
},
},
'/tv/{id}/seasons': {
get: {
tags: ['search'],
summary: 'List seasons for a TV show',
operationId: 'getSeasons',
parameters: [{ $ref: '#/components/parameters/TvId' }],
responses: {
200: {
description: 'season list',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
tmdb_id: { type: 'string' },
count: { type: 'integer' },
seasons: { type: 'array', items: { $ref: '#/components/schemas/Season' } },
},
},
},
},
},
400: { $ref: '#/components/responses/BadRequest' },
502: { $ref: '#/components/responses/Upstream' },
},
},
},
'/tv/{id}/seasons/{season}': {
get: {
tags: ['search'],
summary: 'List episodes for a season',
operationId: 'getEpisodes',
parameters: [
{ $ref: '#/components/parameters/TvId' },
{
name: 'season',
in: 'path',
required: true,
description: 'season number',
schema: { type: 'integer' },
example: 1,
},
],
responses: {
200: {
description: 'episode list',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
tmdb_id: { type: 'string' },
season: { type: 'integer' },
count: { type: 'integer' },
episodes: { type: 'array', items: { $ref: '#/components/schemas/Episode' } },
},
},
},
},
},
400: { $ref: '#/components/responses/BadRequest' },
502: { $ref: '#/components/responses/Upstream' },
},
},
},
'/streams': {
get: {
tags: ['streams'],
summary: 'Fetch m3u8 streams for a movie or TV episode',
operationId: 'getStreams',
description:
'Scrapes every enabled provider in parallel (override with `providers`) and returns deduped, normalized, quality-ranked streams. ' +
'For `type=tv` you must pass `season` and `episode`. Optional filters are applied before ranking.',
parameters: [
{
name: 'tmdb',
in: 'query',
required: true,
description: 'TMDB numeric id',
schema: { type: 'integer' },
example: 438631,
},
{
name: 'type',
in: 'query',
required: false,
description: 'movie (default) or tv',
schema: { type: 'string', enum: ['movie', 'tv'], default: 'movie' },
},
{
name: 'season',
in: 'query',
required: false,
description: 'season number (required when type=tv)',
schema: { type: 'integer' },
},
{
name: 'episode',
in: 'query',
required: false,
description: 'episode number (required when type=tv)',
schema: { type: 'integer' },
},
{
name: 'providers',
in: 'query',
required: false,
description: 'comma-separated provider names; defaults to the enabled set',
schema: { type: 'string' },
example: 'videasy,vidfast',
},
{
name: 'minQuality',
in: 'query',
required: false,
schema: { type: 'string', enum: ['any', '480', '720', '1080', '2160'], default: 'any' },
},
{
name: 'audioCodec',
in: 'query',
required: false,
schema: { type: 'string', enum: ['any', 'aac', 'ac3', 'dts', 'opus', 'mp3', 'flac'], default: 'any' },
},
{
name: 'subtitles',
in: 'query',
required: false,
schema: { type: 'string', enum: ['any', 'required', 'lang'], default: 'any' },
},
{
name: 'subtitleLang',
in: 'query',
required: false,
description: 'two-letter lang code, used when subtitles=lang',
schema: { type: 'string' },
example: 'en',
},
],
responses: {
200: {
description: 'stream list (best quality first)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/StreamList' },
},
},
},
400: { $ref: '#/components/responses/BadRequest' },
500: {
description: 'no providers available / internal error',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
},
components: {
parameters: {
MovieId: {
name: 'id',
in: 'path',
required: true,
description: 'TMDB movie id',
schema: { type: 'integer' },
example: 438631,
},
TvId: {
name: 'id',
in: 'path',
required: true,
description: 'TMDB tv id',
schema: { type: 'integer' },
example: 1396,
},
},
responses: {
BadRequest: {
description: 'missing or invalid parameters',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
NotFound: {
description: 'media id not found on TMDB',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
Upstream: {
description: 'TMDB request failed',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
schemas: {
Error: {
type: 'object',
properties: { error: { type: 'string' } },
},
SearchResult: {
type: 'object',
properties: {
tmdb_id: { type: 'integer', example: 438631 },
type: { type: 'string', enum: ['movie', 'tv'] },
name: { type: 'string', example: 'Dune' },
year: { type: 'string', example: '2021' },
poster: { type: 'string', format: 'uri' },
overview: { type: 'string' },
},
},
MediaDetails: {
type: 'object',
description: 'raw TMDB media object (movie or tv) with added poster_url/backdrop_url',
properties: {
id: { type: 'integer', example: 438631 },
title: { type: 'string', description: 'movie title' },
name: { type: 'string', description: 'tv show name' },
overview: { type: 'string', description: 'description' },
tagline: { type: 'string' },
genres: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
},
},
},
vote_average: { type: 'number', description: 'rating, 0-10' },
vote_count: { type: 'integer' },
popularity: { type: 'number' },
status: { type: 'string' },
runtime: { type: 'integer', description: 'movie runtime in minutes' },
number_of_seasons: { type: 'integer', description: 'tv' },
number_of_episodes: { type: 'integer', description: 'tv' },
release_date: { type: 'string', format: 'date', description: 'movie' },
first_air_date: { type: 'string', format: 'date', description: 'tv' },
poster_path: { type: 'string' },
backdrop_path: { type: 'string' },
poster_url: { type: 'string', format: 'uri', description: 'full url (original size)' },
backdrop_url: { type: 'string', format: 'uri', description: 'full url (original size)' },
images: {
type: 'object',
description:
'all available images from TMDB (posters, backdrops, logos, profiles); every item includes `url` with the full image url',
},
},
additionalProperties: true,
},
Season: {
type: 'object',
properties: {
season_number: { type: 'integer' },
name: { type: 'string' },
episode_count: { type: 'integer' },
air_date: { type: 'string', format: 'date' },
},
},
Episode: {
type: 'object',
properties: {
episode_number: { type: 'integer' },
name: { type: 'string' },
air_date: { type: 'string', format: 'date' },
},
},
Subtitle: {
type: 'object',
properties: {
url: { type: 'string', format: 'uri' },
language: { type: 'string' },
name: { type: 'string' },
},
},
Stream: {
type: 'object',
properties: {
provider: { type: 'string', example: 'videasy' },
name: { type: 'string' },
url: { type: 'string', format: 'uri', description: 'the m3u8 (or direct) url' },
quality: { type: 'string', example: '2160p' },
pixels: { type: ['integer', 'null'], example: 2160 },
audio: { type: ['string', 'null'], example: 'aac' },
type: { type: 'string', example: 'm3u8' },
headers: {
type: 'object',
description: 'http headers the url needs (e.g. Referer)',
additionalProperties: { type: 'string' },
},
subtitles: { type: 'array', items: { $ref: '#/components/schemas/Subtitle' } },
},
},
StreamList: {
type: 'object',
properties: {
tmdb_id: { type: 'integer' },
type: { type: 'string', enum: ['movie', 'tv'] },
season: { type: ['integer', 'null'] },
episode: { type: ['integer', 'null'] },
total: { type: 'integer', description: 'streams before filters' },
matched: { type: 'integer', description: 'streams after filters' },
streams: { type: 'array', items: { $ref: '#/components/schemas/Stream' } },
providerErrors: {
type: 'object',
description: 'per-provider scrape errors',
additionalProperties: { type: 'string' },
},
loadErrors: {
type: 'object',
description: 'per-provider load errors',
additionalProperties: { type: 'string' },
},
},
},
},
},
};
module.exports = spec;
+284
View File
@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>tv.bug.tools api</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
#fallback { font-family: monospace; padding: 2rem; white-space: pre-wrap; }
.swagger-ui .topbar,
.swagger-ui .search { display: none !important; }
:root {
color-scheme: dark;
}
body {
background: #1e1e2e;
color: #cdd6f4;
}
.swagger-ui {
color: #cdd6f4;
background: transparent;
}
.swagger-ui .topbar {
background: #181825;
border-bottom: 1px solid #585b70;
}
.swagger-ui .topbar .download-url-wrapper .select-label {
color: #a6adc8;
}
.swagger-ui .topbar .download-url-wrapper input[type="text"] {
background: #45475a;
border: 1px solid #585b70;
color: #cdd6f4;
}
.swagger-ui .info,
.swagger-ui .info .title,
.swagger-ui .info h1,
.swagger-ui .info h2,
.swagger-ui .info h3,
.swagger-ui .info h4,
.swagger-ui .info h5 {
color: #cdd6f4;
}
.swagger-ui .info .description,
.swagger-ui .info p,
.swagger-ui .info li,
.swagger-ui .info .base-url {
color: #a6adc8;
}
.swagger-ui .scheme-container {
background: #313244;
box-shadow: none;
border: 1px solid #585b70;
}
.swagger-ui .filter .operation-filter-input {
border: 1px solid #585b70;
background: #45475a;
color: #cdd6f4;
}
.swagger-ui .opblock-tag {
color: #cdd6f4;
border-bottom: 1px solid #585b70;
}
.swagger-ui .opblock-tag small {
color: #a6adc8;
}
.swagger-ui .opblock {
border: 1px solid #585b70;
background: #313244;
border-radius: 6px;
margin: 0 0 12px;
box-shadow: none;
}
.swagger-ui .opblock .opblock-summary {
border-color: #585b70;
}
.swagger-ui .opblock .opblock-summary-method {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 600;
min-width: 72px;
}
.swagger-ui .opblock.opblock-get .opblock-summary-method {
background: #89b4fa;
color: #11111b;
}
.swagger-ui .opblock.opblock-post .opblock-summary-method {
background: #a6e3a1;
color: #11111b;
}
.swagger-ui .opblock.opblock-put .opblock-summary-method {
background: #fab387;
color: #11111b;
}
.swagger-ui .opblock.opblock-patch .opblock-summary-method {
background: #cba6f7;
color: #11111b;
}
.swagger-ui .opblock.opblock-delete .opblock-summary-method {
background: #f38ba8;
color: #11111b;
}
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock .opblock-summary-description {
color: #cdd6f4;
}
.swagger-ui .opblock-body {
background: #181825;
border-top: 1px solid #585b70;
}
.swagger-ui .opblock-description-wrapper p,
.swagger-ui .opblock-description-wrapper,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type,
.swagger-ui label {
color: #cdd6f4;
}
.swagger-ui .parameter__extension,
.swagger-ui .parameter__in {
color: #a6adc8;
}
.swagger-ui input[type="text"],
.swagger-ui input[type="password"],
.swagger-ui input[type="search"],
.swagger-ui input[type="email"],
.swagger-ui textarea,
.swagger-ui select {
background: #45475a;
border: 1px solid #585b70;
color: #cdd6f4;
border-radius: 4px;
}
.swagger-ui input[type="text"]:focus,
.swagger-ui textarea:focus,
.swagger-ui select:focus {
outline: 2px solid #89b4fa;
outline-offset: 1px;
}
.swagger-ui .btn {
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.swagger-ui .btn.cancel {
background: #45475a;
border-color: #585b70;
color: #cdd6f4;
}
.swagger-ui .btn.execute {
background: #89b4fa;
border-color: #89b4fa;
color: #11111b;
font-weight: 600;
}
.swagger-ui .btn.execute:hover {
filter: brightness(1.08);
}
.swagger-ui .responses-inner h4,
.swagger-ui .responses-inner h5,
.swagger-ui .tab li {
color: #cdd6f4;
}
.swagger-ui .response-col_status,
.swagger-ui .response-col_links {
color: #a6adc8;
}
.swagger-ui table thead tr th,
.swagger-ui table thead tr td {
background: #45475a;
border-bottom: 1px solid #585b70;
color: #cdd6f4;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.swagger-ui table tbody tr td {
border-bottom: 1px solid #585b70;
color: #cdd6f4;
}
.swagger-ui .model-box,
.swagger-ui .model,
.swagger-ui .renderedMarkdown code,
.swagger-ui .microlight,
.swagger-ui .highlight-code pre {
background: #11111b;
color: #cdd6f4;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
border: 1px solid #585b70;
}
.swagger-ui .prop-type,
.swagger-ui .prop-format {
color: #94e2d5;
}
.swagger-ui .model-title,
.swagger-ui .model-hint {
color: #a6adc8;
}
.swagger-ui section.models {
border: 1px solid #585b70;
background: #313244;
}
.swagger-ui section.models.is-open h4 {
border-bottom: 1px solid #585b70;
color: #cdd6f4;
}
.swagger-ui .dialog-ux .modal-ux {
background: #181825;
border: 1px solid #585b70;
color: #cdd6f4;
}
.swagger-ui .dialog-ux .modal-ux-header h3 {
color: #cdd6f4;
}
.swagger-ui .errors-wrapper {
background: rgba(243, 139, 168, 0.12);
border: 1px solid #f38ba8;
color: #cdd6f4;
}
.swagger-ui .loading-container .loading::after {
color: #94e2d5;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.addEventListener('load', function () {
var ui = SwaggerUIBundle({
url: '/api.json',
dom_id: '#swagger-ui',
deepLinking: true,
defaultModelsExpandDepth: -1,
presets: [SwaggerUIBundle.presets.apis],
layout: 'BaseLayout',
requestInterceptor: function (req) { return req; },
});
window.ui = ui;
});
</script>
</body>
</html>
+524
View File
@@ -0,0 +1,524 @@
/* https://github.com/oqo0/swagger-themes */
@import url('https://unpkg.com/@catppuccin/palette/css/catppuccin.css');
:root {
--block-border-radius: 8px;
--innner-block-border-radius: 4px;
--icons-opacity: .32;
--secondary-icon-opacity: .6;
--black-icons-filter: invert(1);
}
[data-md-color-scheme="catppuccin-mocha"] {
/* primary colors */
--swagger-color: var(--ctp-latte-green);
--link-color: var(--ctp-latte-blue);
--accept-header-color: var(--ctp-latte-green);
/* methods colors */
--post-method-color: var(--ctp-latte-green);
--post-method-background-color: color-mix(in srgb, var(--ctp-latte-green), transparent 90%);
--get-method-color: var(--ctp-latte-blue);
--get-method-background-color: color-mix(in srgb, var(--ctp-latte-blue), transparent 90%);
--head-method-color: var(--ctp-latte-mauve);
--head-method-background-color: color-mix(in srgb, var(--ctp-latte-mauve), transparent 90%);
--put-method-color: var(--ctp-latte-peach);
--put-method-background-color: color-mix(in srgb, var(--ctp-latte-peach), transparent 90%);
--delete-method-color: var(--ctp-latte-red);
--delete-method-background-color: color-mix(in srgb, var(--ctp-latte-red), transparent 90%);
--options-method-color: var(--ctp-latte-sky);
--options-method-background-color: color-mix(in srgb, var(--ctp-latte-sky), transparent 90%);
--patch-method-color: var(--ctp-latte-teal);
--patch-method-background-color: color-mix(in srgb, var(--ctp-latte-teal), transparent 90%);
/* background */
--all-bg-color: var(--ctp-latte-base);
--secondary-bg-color: var(--ctp-latte-mantle);
--header-bg-color: var(--ctp-latte-mantle);
--block-bg-color: var(--ctp-latte-mantle);
--selecter-bg-color: var(--ctp-latte-mantle);
--teteriary-bg-color: var(--ctp-latte-crust);
/* text */
--primary-text-color: var(--ctp-latte-text);
--secondary-text-color: var(--ctp-latte-subtext0);
/* border */
--block-border-color: var(--ctp-latte-surface0);
/* icons */
--primary-icon-color: var(--ctp-latte-text);
}
[data-md-color-scheme="catppuccin-latte"] {
/* primary colors */
--swagger-color: var(--ctp-mocha-green);
--link-color: var(--ctp-mocha-blue);
--accept-header-color: var(--ctp-mocha-green);
/* methods colors */
--post-method-color: var(--ctp-mocha-green);
--post-method-background-color: color-mix(in srgb, var(--ctp-mocha-green), transparent 90%);
--get-method-color: var(--ctp-mocha-blue);
--get-method-background-color: color-mix(in srgb, var(--ctp-mocha-blue), transparent 90%);
--head-method-color: var(--ctp-mocha-mauve);
--head-method-background-color: color-mix(in srgb, var(--ctp-mocha-mauve), transparent 90%);
--put-method-color: var(--ctp-mocha-peach);
--put-method-background-color: color-mix(in srgb, var(--ctp-mocha-peach), transparent 90%);
--delete-method-color: var(--ctp-mocha-red);
--delete-method-background-color: color-mix(in srgb, var(--ctp-mocha-red), transparent 90%);
--options-method-color: var(--ctp-mocha-sky);
--options-method-background-color: color-mix(in srgb, var(--ctp-mocha-sky), transparent 90%);
--patch-method-color: var(--ctp-mocha-teal);
--patch-method-background-color: color-mix(in srgb, var(--ctp-mocha-teal), transparent 90%);
/* background */
--all-bg-color: var(--ctp-mocha-base);
--secondary-bg-color: var(--ctp-mocha-mantle);
--header-bg-color: var(--ctp-mocha-mantle);
--block-bg-color: var(--ctp-mocha-mantle);
--selecter-bg-color: var(--ctp-mocha-mantle);
--teteriary-bg-color: var(--ctp-mocha-crust);
/* text */
--primary-text-color: var(--ctp-mocha-text);
--secondary-text-color: var(--ctp-mocha-subtext0);
/* border */
--block-border-color: var(--ctp-mocha-surface0);
/* icons */
--primary-icon-color: var(--ctp-mocha-text);
}
/* primary colors */
.swagger-ui .topbar .download-url-wrapper .select-label select {
border: 2px solid var(--swagger-color);
}
.swagger-ui .info .title small.version-stamp {
background-color: var(--swagger-color);
}
.swagger-ui .info a {
color: var(--link-color);
}
.swagger-ui .response-control-media-type--accept-controller select {
border-color: var(--accept-header-color);
}
.swagger-ui .response-control-media-type__accept-message {
color: var(--accept-header-color);
}
.swagger-ui .btn.authorize {
color: var(--post-method-color);
}
.swagger-ui .btn.authorize {
border-color: var(--post-method-color);
}
.swagger-ui .btn.authorize svg {
fill: var(--post-method-color);
}
.swagger-ui .json-schema-2020-12__attribute--primary {
color: var(--head-method-color);
}
/* methods colors */
/* http post */
.swagger-ui .opblock.opblock-post .opblock-summary-method {
background: var(--post-method-color);
}
.swagger-ui .opblock.opblock-post .opblock-summary {
border-color: var(--post-method-color);
}
.swagger-ui .opblock.opblock-post {
background: var(--post-method-background-color);
border-color: var(--post-method-color);
}
.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after {
background: var(--post-method-color);
}
/* http get */
.swagger-ui .opblock.opblock-get .opblock-summary-method {
background: var(--get-method-color);
}
.swagger-ui .opblock.opblock-get .opblock-summary {
border-color: var(--get-method-color);
}
.swagger-ui .opblock.opblock-get {
background: var(--get-method-background-color);
border-color: var(--get-method-color);
}
.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after {
background: var(--get-method-color);
}
/* http head */
.swagger-ui .opblock.opblock-head .opblock-summary-method {
background: var(--head-method-color);
}
.swagger-ui .opblock.opblock-head .opblock-summary {
border-color: var(--head-method-color);
}
.swagger-ui .opblock.opblock-head {
background: var(--head-method-background-color);
border-color: var(--head-method-color);
}
.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after {
background: var(--head-method-color);
}
/* http put */
.swagger-ui .opblock.opblock-put .opblock-summary-method {
background: var(--put-method-color);
}
.swagger-ui .opblock.opblock-put .opblock-summary {
border-color: var(--put-method-color);
}
.swagger-ui .opblock.opblock-put {
background: var(--put-method-background-color);
border-color: var(--put-method-color);
}
.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after {
background: var(--put-method-color);
}
/* http delete */
.swagger-ui .opblock.opblock-delete .opblock-summary-method {
background: var(--delete-method-color);
}
.swagger-ui .opblock.opblock-delete .opblock-summary {
border-color: var(--delete-method-color);
}
.swagger-ui .opblock.opblock-delete {
background: var(--delete-method-background-color);
border-color: var(--delete-method-color);
}
.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after {
background: var(--delete-method-color);
}
/* http options */
.swagger-ui .opblock.opblock-options .opblock-summary-method {
background: var(--options-method-color);
}
.swagger-ui .opblock.opblock-options .opblock-summary {
border-color: var(--options-method-color);
}
.swagger-ui .opblock.opblock-options {
background: var(--options-method-background-color);
border-color: var(--options-method-color);
}
.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after {
background: var(--options-method-color);
}
/* http patch */
.swagger-ui .opblock.opblock-patch .opblock-summary-method {
background: var(--patch-method-color);
}
.swagger-ui .opblock.opblock-patchs .opblock-summary {
border-color: var(--patch-method-color);
}
.swagger-ui .opblock.opblock-patch {
background: var(--patch-method-background-color);
border-color: var(--patch-method-color);
}
.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after {
background: var(--patch-method-color);
}
/* blocks */
body {
background-color: var(--all-bg-color);
color: white;
}
.swagger-ui .topbar {
background-color: var(--header-bg-color);
}
.swagger-ui .scheme-container {
background: var(--secondary-bg-color);
}
.swagger-ui section.models .model-container {
background: var(--secondary-bg-color);
border-radius: var(--innner-block-border-radius);
}
.swagger-ui select {
background: var(--selecter-bg-color);
border-radius: var(--block-border-radius);
color: var(--primary-text-color);
}
.swagger-ui section.models {
border: 1px solid var(--block-border-color);
background-color: var(--block-bg-color);
}
.swagger-ui .opblock .opblock-section-header {
background: var(--secondary-bg-color);
}
.swagger-ui .body-param__example {
background-color: var(--secondary-bg-color) !important;
border-radius: var(--block-border-radius) !important;
}
.swagger-ui .example {
background-color: var(--secondary-bg-color) !important;
border-radius: var(--block-border-radius) !important;
}
.swagger-ui .copy-to-clipboard {
background: rgba(255, 255, 255, var(--icons-opacity));
border-radius: var(--block-border-radius);
}
.swagger-ui .opblock .opblock-summary-method {
border-radius: var(--innner-block-border-radius);
}
.swagger-ui input[type="email"],
.swagger-ui input[type="file"],
.swagger-ui input[type="password"],
.swagger-ui input[type="search"],
.swagger-ui input[type="text"],
.swagger-ui textarea {
background: var(--secondary-bg-color);
border: 1px solid var(--block-border-color);
border-radius: var(--block-border-radius);
color: var(--primary-text-color);
outline: none;
}
.swagger-ui .dialog-ux .modal-ux-header {
border-bottom: 1px solid var(--block-border-color);
}
.swagger-ui .btn {
border: 2px solid var(--block-border-color);
border-radius: var(--block-border-radius);
color: var(--primary-text-color);
}
.swagger-ui .dialog-ux .modal-ux {
background: var(--block-bg-color);
border: 1px solid var(--block-border-color);
border-radius: var(--block-border-radius);
}
.swagger-ui .auth-btn-wrapper {
justify-content: left;
}
.swagger-ui .opblock-tag {
border-bottom: 1px solid var(--block-border-color);
}
.swagger-ui section.models.is-open h4 {
border-bottom: 1px solid var(--block-border-color);
}
.swagger-ui .opblock {
border-radius: var(--block-border-radius);
}
.swagger-ui section.models {
border-radius: var(--block-border-radius);
}
/* button white outline fix */
.swagger-ui .model-box-control:focus,
.swagger-ui .models-control:focus,
.swagger-ui .opblock-summary-control:focus {
outline: none;
}
/* icons */
.swagger-ui .model-toggle::after {
opacity: var(--icons-opacity);
filter: var(--black-icons-filter);
}
.swagger-ui svg:not(:root) {
fill: var(--primary-icon-color);
}
.swagger-ui .opblock-summary-control svg:not(:root) {
opacity: var(--secondary-icon-opacity);
}
/* text */
.swagger-ui {
color: var(--primary-text-color);
}
.swagger-ui .info .title {
color: var(--primary-text-color);
}
.swagger-ui a.nostyle {
color: var(--primary-text-color);
}
.swagger-ui .model-title {
color: var(--primary-text-color);
}
.swagger-ui .models-control {
color: var(--primary-text-color);
}
.swagger-ui .dialog-ux .modal-ux-header h3 {
color: var(--primary-text-color);
}
.swagger-ui .dialog-ux .modal-ux-content h4 {
color: var(--primary-text-color);
}
.swagger-ui .dialog-ux .modal-ux-content p {
color: var(--secondary-text-color);
}
.swagger-ui ol {
color: var(--primary-text-color);
}
.swagger-ui h3 {
color: var(--primary-text-color) !important;
}
.swagger-ui h4 {
color: var(--primary-text-color) !important;
}
.swagger-ui h5 {
color: var(--secondary-text-color) !important;
}
.swagger-ui label {
color: var(--primary-text-color);
}
.swagger-ui .opblock .opblock-section-header h4 {
color: var(--primary-text-color);
}
.swagger-ui .tab li button.tablinks {
color: var(--primary-text-color);
}
.swagger-ui .opblock-description-wrapper p,
.swagger-ui .opblock-external-docs-wrapper p,
.swagger-ui .opblock-title_normal p {
color: var(--primary-text-color);
}
.opblock-summary-description {
color: var(--secondary-text-color) !important;
}
.swagger-ui table thead tr td, .swagger-ui table thead tr th {
border-bottom: 1px solid var(--block-border-color);
color: var(--primary-text-color);
}
.swagger-ui .response-col_status {
color: var(--primary-text-color);
}
.swagger-ui .response-col_links {
color: var(--secondary-text-color);
}
.swagger-ui .parameter__name {
color: var(--primary-text-color);
}
.swagger-ui .parameter__type {
color: var(--secondary-text-color);
}
.swagger-ui .prop-format {
color: var(--secondary-text-color);
}
.swagger-ui .info li,
.swagger-ui .info p,
.swagger-ui .info table {
color: var(--secondary-text-color);
}
.swagger-ui .model {
color: var(--secondary-text-color);
}
.swagger-ui .opblock-tag small {
color: var(--secondary-text-color);
}
.swagger-ui .json-schema-2020-12__title,
.swagger-ui .json-schema-2020-12-keyword__name--primary,
.swagger-ui .json-schema-2020-12-property .json-schema-2020-12__title {
color: var(--primary-text-color);
}
.swagger-ui .json-schema-2020-12-expand-deep-button {
color: var(--secondary-text-color);
}
/* json */
.swagger-ui .json-schema-2020-12-accordion,
.swagger-ui .json-schema-2020-12-expand-deep-button {
background: none;
}
.swagger-ui .json-schema-2020-12 {
background-color: var(--teteriary-bg-color);
}
.swagger-ui .json-schema-2020-12__constraint--string {
background-color: var(--put-method-color);
}
+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(', ')}`);
});
+132
View File
@@ -0,0 +1,132 @@
/*
BASE reset, typography, page frame (header/footer/container).
Layout-level stuff only; reusable widgets live in components.css.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: var(--text-md);
line-height: var(--leading-normal);
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
}
img {
max-width: 100%;
display: block;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
h1,
h2,
h3 {
margin: 0;
line-height: var(--leading-tight);
}
p {
margin: 0;
}
/* ---- page frame ---------------------------------------------------------- */
.container {
max-width: var(--container-max);
margin: 0 auto;
padding-left: var(--container-pad);
padding-right: var(--container-pad);
}
.page {
padding-top: var(--space-5);
padding-bottom: var(--space-8);
}
.site-header {
position: sticky;
top: 0;
z-index: 50;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
background: color-mix(in srgb, var(--color-bg) 85%, transparent);
backdrop-filter: blur(12px);
}
.site-header__inner {
display: flex;
align-items: center;
gap: var(--space-4);
height: var(--header-height);
}
.brand {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
letter-spacing: -0.02em;
}
.brand span {
color: var(--color-accent);
}
.site-header__nav {
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-dim);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
}
.site-header__nav:hover {
color: var(--color-text);
background: var(--color-surface);
}
.site-header__spacer {
flex: 1;
}
.site-footer {
border-top: 1px solid var(--color-border);
padding: var(--space-4) 0 var(--space-5);
color: var(--color-text-faint);
font-size: var(--text-xs);
}
.site-footer .container {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.site-footer a {
color: var(--color-text-dim);
}
.site-footer a:hover {
color: var(--color-text);
}
+421
View File
@@ -0,0 +1,421 @@
/*
COMPONENTS reusable widgets. Each block is self-contained and named
after the thing it styles (BEM-ish: block, block__element, block--modifier).
Theme values come from tokens.css; never hardcode colors/sizes here if a
token exists.
*/
/* ---- buttons ------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
line-height: 1.3;
cursor: pointer;
user-select: none;
transition: background 120ms ease, border-color 120ms ease;
}
.btn:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-strong);
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.btn--primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-text);
}
.btn--primary:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.btn--ghost {
background: transparent;
border-color: transparent;
color: var(--color-text-dim);
}
.btn--ghost:hover {
background: var(--color-surface);
color: var(--color-text);
}
.btn--sm {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
}
.btn:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
/* ---- form fields --------------------------------------------------------- */
.field {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text);
font: inherit;
font-size: var(--text-sm);
}
.field::placeholder {
color: var(--color-text-faint);
}
.field:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 25%, transparent);
}
select.field {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%2398a2b0' stroke-width='2'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.field-label {
display: block;
margin-bottom: var(--space-1);
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-dim);
}
/* ---- badges & tags -------------------------------------------------------- */
.badge {
display: inline-flex;
align-items: center;
padding: 1px var(--space-2);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-dim);
white-space: nowrap;
}
.badge--accent {
border-color: var(--color-accent);
color: var(--color-accent);
}
.tag {
display: inline-block;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
font-size: var(--text-xs);
color: var(--color-text-dim);
}
/* ---- chips (filters) ------------------------------------------------------- */
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: transparent;
color: var(--color-text-dim);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.chip:hover {
border-color: var(--color-border-strong);
color: var(--color-text);
}
.chip--active {
background: var(--color-surface);
border-color: var(--color-accent);
color: var(--color-text);
}
/* ---- media cards (poster) --------------------------------------------------- */
.card {
display: block;
flex: 0 0 var(--card-width);
width: var(--card-width);
cursor: pointer;
}
.card__poster {
position: relative;
aspect-ratio: var(--poster-aspect);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface);
border: 1px solid var(--color-border);
transition: border-color 120ms ease, transform 120ms ease;
}
.card:hover .card__poster {
border-color: var(--color-border-strong);
transform: translateY(-2px);
}
.card__poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card__poster--empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-faint);
font-size: var(--text-2xl);
}
.card__title {
margin-top: var(--space-2);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card__meta {
margin-top: 2px;
font-size: var(--text-xs);
color: var(--color-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card__progress {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: rgba(255, 255, 255, 0.15);
}
.card__progress i {
display: block;
height: 100%;
background: var(--color-accent);
}
/* ---- rows (horizontal carousels) ------------------------------------------ */
.row {
margin-bottom: var(--space-6);
}
.row__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.row__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
}
.row__link {
font-size: var(--text-sm);
color: var(--color-text-dim);
}
.row__link:hover {
color: var(--color-text);
}
.row__scroll {
display: flex;
gap: var(--space-3);
overflow-x: auto;
scroll-snap-type: x proximity;
padding-bottom: var(--space-2);
scrollbar-width: thin;
scrollbar-color: var(--color-border-strong) transparent;
}
.row__scroll .card {
scroll-snap-align: start;
}
/* ---- results grid ---------------------------------------------------------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
gap: var(--space-4);
}
.grid .card {
flex: none;
width: auto;
}
/* ---- modal ----------------------------------------------------------------- */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.modal {
width: 100%;
max-width: 460px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-2);
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
}
.modal__body {
padding: var(--space-5);
display: grid;
gap: var(--space-4);
}
.modal__hint {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
}
/* ---- toast ----------------------------------------------------------------- */
.toast {
position: fixed;
left: 50%;
bottom: var(--space-6);
z-index: 110;
transform: translateX(-50%);
max-width: min(90vw, 480px);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
box-shadow: var(--shadow-2);
font-size: var(--text-sm);
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease, transform 150ms ease;
}
.toast--show {
opacity: 1;
}
.toast--error {
border-color: var(--color-danger);
color: var(--color-danger);
}
/* ---- loading & empty states ------------------------------------------------- */
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner--center {
padding: var(--space-8) 0;
text-align: center;
}
.empty {
padding: var(--space-7) 0;
text-align: center;
color: var(--color-text-faint);
}
.empty h3 {
font-size: var(--text-lg);
color: var(--color-text-dim);
margin-bottom: var(--space-2);
}
+447
View File
@@ -0,0 +1,447 @@
/*
PAGES page-specific layout: home, search, watch.
Widget styling lives in components.css; this file only positions
page-level blocks and handles responsive behavior.
*/
/* ---- home ------------------------------------------------------------------ */
.home-hero {
margin: 0 0 var(--space-6);
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background:
radial-gradient(80% 120% at 10% 0%, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 60%),
var(--color-surface);
}
.home-hero h1 {
font-size: var(--text-2xl);
letter-spacing: -0.02em;
}
.home-hero p {
margin-top: var(--space-2);
max-width: 56ch;
color: var(--color-text-dim);
font-size: var(--text-sm);
}
.home-hero .chips {
margin-top: var(--space-4);
}
/* ---- search ------------------------------------------------------------------ */
.search-bar {
margin-bottom: var(--space-5);
}
.search-bar__form {
display: flex;
gap: var(--space-2);
}
.search-bar__input {
flex: 1;
font-size: var(--text-md);
padding: var(--space-3) var(--space-4);
}
.search-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-4);
}
.search-count {
font-size: var(--text-sm);
color: var(--color-text-faint);
}
/* ---- watch: hero ------------------------------------------------------------- */
.watch-hero {
position: relative;
display: flex;
gap: var(--space-5);
align-items: flex-end;
margin-bottom: var(--space-5);
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
}
.watch-hero__backdrop {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.watch-hero__backdrop img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.25;
}
.watch-hero__poster {
position: relative;
z-index: 1;
flex: 0 0 180px;
width: 180px;
aspect-ratio: var(--poster-aspect);
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border-strong);
box-shadow: var(--shadow-2);
background: var(--color-surface-2);
}
.watch-hero__poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.watch-hero__body {
position: relative;
z-index: 1;
min-width: 0;
flex: 1;
}
.watch-hero__title {
font-size: var(--text-3xl);
letter-spacing: -0.02em;
margin-bottom: var(--space-2);
}
.watch-hero__facts {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
font-size: var(--text-sm);
color: var(--color-text-dim);
}
.watch-hero__rating {
color: var(--color-star);
font-weight: var(--font-weight-semibold);
}
.watch-hero__genres {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
}
.watch-hero__overview {
margin-top: var(--space-4);
max-width: 72ch;
color: var(--color-text-dim);
font-size: var(--text-sm);
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ---- watch: player ------------------------------------------------------------- */
.player-wrap {
position: relative;
aspect-ratio: var(--player-aspect);
border-radius: var(--radius-md);
overflow: hidden;
background: #000;
border: 1px solid var(--color-border);
}
.player-wrap video {
width: 100%;
height: 100%;
display: block;
background: #000;
}
.player-status {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
background: rgba(0, 0, 0, 0.75);
color: var(--color-text-dim);
font-size: var(--text-sm);
text-align: center;
padding: var(--space-5);
}
.player-status h3 {
font-size: var(--text-md);
color: var(--color-text);
}
.player-status .spinner {
margin-bottom: var(--space-1);
}
/* ---- watch: toolbar + sources ---------------------------------------------------- */
.watch-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin: var(--space-4) 0 var(--space-5);
font-size: var(--text-sm);
color: var(--color-text-dim);
}
.watch-toolbar__source {
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
.watch-toolbar__spacer {
flex: 1;
}
.sources {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
margin-bottom: var(--space-6);
overflow: hidden;
}
.sources__head {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
}
.source-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
text-align: left;
cursor: pointer;
font-size: var(--text-sm);
}
.source-item:last-child {
border-bottom: none;
}
.source-item:hover {
background: var(--color-surface-hover);
}
.source-item--active {
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
}
.source-item__rank {
flex: 0 0 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
font-size: var(--text-xs);
color: var(--color-text-dim);
}
.source-item--active .source-item__rank {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-text);
}
.source-item__name {
font-weight: var(--font-weight-medium);
}
.source-item__meta {
margin-left: auto;
color: var(--color-text-faint);
font-size: var(--text-xs);
white-space: nowrap;
}
.loading-cell {
grid-column: 1 / -1;
display: flex;
justify-content: center;
padding: var(--space-8) 0;
}
/* ---- watch: episodes --------------------------------------------------------- */
.episodes {
margin-top: var(--space-7);
}
.episodes__toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-4);
}
.episodes__title {
font-size: var(--text-xl);
font-weight: var(--font-weight-semibold);
margin-right: auto;
}
.episodes__toolbar select.field {
width: auto;
}
.episode-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-3);
}
.episode {
display: flex;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.episode:hover {
border-color: var(--color-border-strong);
background: var(--color-surface-hover);
}
.episode--current {
border-color: var(--color-accent);
}
.episode__thumb {
position: relative;
flex: 0 0 108px;
width: 108px;
aspect-ratio: 16 / 9;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-2);
}
.episode__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.episode__play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-lg);
color: #fff;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
transition: opacity 120ms ease;
}
.episode:hover .episode__play {
opacity: 1;
}
.episode--current .episode__play {
opacity: 1;
background: color-mix(in srgb, var(--color-accent) 35%, rgba(0, 0, 0, 0.35));
}
.episode__body {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.episode__num {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.episode__title {
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.episode__meta {
margin-top: 2px;
font-size: var(--text-xs);
color: var(--color-text-faint);
}
/* ---- responsive ---------------------------------------------------------------- */
@media (max-width: 720px) {
:root {
--card-width: 132px;
}
.watch-hero {
flex-direction: column;
align-items: flex-start;
padding: var(--space-4);
}
.watch-hero__poster {
flex-basis: auto;
width: 132px;
}
.watch-hero__title {
font-size: var(--text-2xl);
}
.home-hero h1 {
font-size: var(--text-xl);
}
.search-bar__form {
flex-direction: column;
}
}
+68
View File
@@ -0,0 +1,68 @@
/*
============================================================================
DESIGN TOKENS the only file you need to touch to restyle the whole UI.
Every color, font size, spacing step, radius and shadow used elsewhere
references a variable defined here. Change a value and it applies everywhere.
============================================================================
*/
:root {
/* ---- palette (retheme here) ----------------------------------------- */
--color-bg: #0b0d10; /* page background */
--color-surface: #14171c; /* cards, panels */
--color-surface-hover: #1c2129; /* hover state for surfaces */
--color-surface-2: #191d24; /* inputs, alt surfaces */
--color-border: #262c35; /* default borders */
--color-border-strong: #39424f; /* visible borders / hover borders */
--color-text: #eceff3; /* primary text */
--color-text-dim: #98a2b0; /* secondary text */
--color-text-faint: #5f6b7a; /* tertiary text */
--color-accent: #4f8cff; /* brand / primary action color */
--color-accent-hover: #6ea1ff;
--color-accent-text: #ffffff; /* text on accent buttons */
--color-danger: #ff6b6b;
--color-success: #46c98f;
--color-star: #ffc94d; /* rating stars */
/* ---- typography ------------------------------------------------------ */
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--leading-tight: 1.2;
--leading-normal: 1.55;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* ---- spacing (4px scale) ---------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
/* ---- shape ------------------------------------------------------------- */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 999px;
--shadow-1: 0 2px 8px rgba(0, 0, 0, 0.35);
--shadow-2: 0 12px 40px rgba(0, 0, 0, 0.5);
/* ---- layout ------------------------------------------------------------ */
--container-max: 1280px;
--container-pad: var(--space-5);
--header-height: 64px;
--poster-aspect: 2 / 3; /* 2:3 poster cards */
--card-width: 160px; /* poster card width in carousels */
--player-aspect: 16 / 9;
}
+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>
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bug.tv — home</title>
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/pages.css">
</head>
<body>
<header class="site-header">
<div class="container site-header__inner">
<a class="brand" href="index.html">bug<span>.tv</span></a>
<a class="site-header__nav" href="index.html">Home</a>
<a class="site-header__nav" href="search.html">Search</a>
<span class="site-header__spacer"></span>
<button class="btn btn--ghost btn--sm" id="settingsBtn" type="button">Settings</button>
</div>
</header>
<main class="container page" id="content">
<section class="home-hero">
<h1>What do you want to watch?</h1>
<p>
One catalog, many sources. Pick a stream, and if it dies mid-episode,
switch source — your place is remembered.
</p>
<form class="search-bar__form" id="heroForm" role="search">
<input class="field search-bar__input" id="heroInput" type="search" placeholder="Search movies and shows…" autocomplete="off">
<button class="btn btn--primary" type="submit">Search</button>
</form>
<div class="chips" id="heroChips"></div>
</section>
<div id="sections"></div>
</main>
<footer class="site-footer">
<div class="container">
<span>bug.tv</span>
<span>API: <a id="apiBaseLabel" href="#" target="_blank" rel="noopener"></a></span>
</div>
</footer>
<div class="modal-overlay" id="settingsModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<div class="modal__head">
<h2 class="modal__title" id="settingsTitle">Settings</h2>
<button class="btn btn--ghost btn--sm" id="settingsClose" type="button">Close</button>
</div>
<div class="modal__body">
<div>
<label class="field-label" for="apiBaseInput">API base URL</label>
<input class="field" id="apiBaseInput" type="url" placeholder="https://…">
<p class="modal__hint">
All requests go to this API. You can also override it for one session
by adding <code>?api=https://…</code> to any page URL.
</p>
</div>
</div>
<div class="modal__actions">
<button class="btn" id="apiBaseReset" type="button">Reset to default</button>
<button class="btn btn--primary" id="apiBaseSave" type="button">Save</button>
</div>
</div>
</div>
<script src="js/util.js"></script>
<script src="js/config.js"></script>
<script src="js/api.js"></script>
<script src="js/storage.js"></script>
<script src="js/cards.js"></script>
<script src="js/app.js"></script>
<script src="js/home.js"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
(function () {
'use strict';
async function request(path, params) {
const url = new URL(SR.config.getBase() + path);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, String(value));
}
}
let res;
try {
res = await fetch(url.toString());
} catch (e) {
throw new Error(`cannot reach the API at ${SR.config.getBase()} — check the base URL in Settings`);
}
let data = null;
try {
data = await res.json();
} catch (e) {
/* non-JSON body */
}
if (!res.ok) {
throw new Error((data && data.error) || `API error: HTTP ${res.status}`);
}
return data;
}
// route a media/subtitle URL through the API proxy (CORS, headers, m3u8 rewrite)
function proxyUrl(target, headers) {
const q = new URLSearchParams();
q.set('url', target);
q.set('h', JSON.stringify(headers || {}));
return SR.config.getBase() + '/proxy/media?' + q.toString();
}
window.SR = window.SR || {};
SR.api = {
request,
proxyUrl,
search: (q) => request('/search', { q }),
trending: (type, timeWindow) => request('/trending', { type, window: timeWindow }),
details: (type, id) => request(`/${type}/${id}`),
seasons: (id) => request(`/tv/${id}/seasons`),
episodes: (id, season) => request(`/tv/${id}/seasons/${season}`),
streams: ({ tmdb, type, season, episode }) => request('/streams', { tmdb, type, season, episode }),
};
})();
+79
View File
@@ -0,0 +1,79 @@
/*
APP shared chrome for every page: settings dialog, footer API label,
toast notifications.
*/
(function () {
'use strict';
function initChrome() {
const modal = document.getElementById('settingsModal');
const openBtn = document.getElementById('settingsBtn');
const input = document.getElementById('apiBaseInput');
if (openBtn && modal && input) {
const close = () => {
modal.hidden = true;
};
openBtn.addEventListener('click', () => {
input.value = SR.config.getBase();
modal.hidden = false;
input.focus();
input.select();
});
const closeBtn = document.getElementById('settingsClose');
if (closeBtn) closeBtn.addEventListener('click', close);
modal.addEventListener('click', (e) => {
if (e.target === modal) close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.hidden) close();
});
const saveBtn = document.getElementById('apiBaseSave');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
SR.config.setBase(input.value);
location.reload();
});
}
const resetBtn = document.getElementById('apiBaseReset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
SR.config.clearBase();
location.reload();
});
}
}
const label = document.getElementById('apiBaseLabel');
if (label) {
const base = SR.config.getBase();
label.textContent = base;
label.href = base;
}
}
let toastTimer = null;
function toast(message, isError) {
let el = document.getElementById('toast');
if (!el) {
el = document.createElement('div');
el.id = 'toast';
el.className = 'toast';
el.setAttribute('role', 'status');
document.body.appendChild(el);
}
el.textContent = message;
el.classList.toggle('toast--error', !!isError);
el.classList.add('toast--show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('toast--show'), 4500);
}
function showError(err, fallback) {
toast(err && err.message ? err.message : fallback || 'Something went wrong', true);
}
window.SR = window.SR || {};
SR.app = { initChrome, toast, showError };
})();
+108
View File
@@ -0,0 +1,108 @@
/*
CARDS renders poster cards, rows (carousels) and grids.
Items can come straight from the API ({tmdb_id, type, name, year, poster})
or from storage records; normalizeItem() handles both.
*/
(function () {
'use strict';
const { escapeHtml: esc, tmdbResize, formatSeconds } = SR.util;
function normalizeItem(r) {
return {
tmdbId: r.tmdb_id != null ? r.tmdb_id : r.tmdbId,
type: r.type,
season: r.season,
episode: r.episode,
name: r.name || r.title || '',
year: r.year || '',
poster: r.poster || r.poster_url || '',
overview: r.overview || '',
};
}
function watchHref(item) {
const p = new URLSearchParams();
p.set('id', item.tmdbId);
p.set('type', item.type);
if (item.type === 'tv') {
if (item.season != null) p.set('s', item.season);
if (item.episode != null) p.set('e', item.episode);
}
return `watch.html?${p.toString()}`;
}
function card(item, opts = {}) {
const el = document.createElement('a');
el.className = 'card';
el.href = opts.href || watchHref(item);
const poster = tmdbResize(item.poster, 'w342');
const progress =
opts.progress != null
? `<span class="card__progress"><i style="width:${Math.round(Math.min(1, opts.progress) * 100)}%"></i></span>`
: '';
const meta =
item.meta ||
(item.year
? `${item.year} · ${item.type === 'tv' ? 'TV' : 'Movie'}`
: item.type === 'tv'
? 'TV'
: 'Movie');
el.innerHTML = `
<span class="card__poster${poster ? '' : ' card__poster--empty'}">
${poster ? `<img src="${poster}" alt="" loading="lazy">` : '?'}
${progress}
</span>
<span class="card__title">${esc(item.name)}</span>
<span class="card__meta">${esc(meta)}</span>`;
return el;
}
function row(title, items) {
const section = document.createElement('section');
section.className = 'row';
section.innerHTML = `<div class="row__header"><h2 class="row__title">${esc(title)}</h2></div>`;
const scroll = document.createElement('div');
scroll.className = 'row__scroll';
for (const item of items) scroll.appendChild(card(item));
section.appendChild(scroll);
return section;
}
function grid(items) {
const el = document.createElement('div');
el.className = 'grid';
for (const item of items) el.appendChild(card(item));
return el;
}
function emptyState(title, hint) {
const el = document.createElement('div');
el.className = 'empty';
el.innerHTML = `<h3>${esc(title)}</h3>${hint ? `<p>${esc(hint)}</p>` : ''}`;
return el;
}
// "Continue watching" row: latest record per show, with progress bar
function continueRow() {
const recs = SR.storage.latestPerShow();
if (!recs.length) return null;
const items = recs.map((r) => ({
tmdbId: r.tmdbId,
type: r.type,
season: r.season,
episode: r.episode,
name: r.show || r.title || '',
poster: r.poster || '',
meta:
r.type === 'tv'
? `S${r.season}E${r.episode}${r.title ? ` · ${r.title}` : ''} · ${formatSeconds(Math.max(0, r.d - r.t))} left`
: `Movie · ${formatSeconds(Math.max(0, r.d - r.t))} left`,
progress: r.d ? r.t / r.d : 0,
}));
return row('Continue watching', items);
}
window.SR = window.SR || {};
SR.cards = { normalizeItem, card, row, grid, emptyState, continueRow };
})();
+52
View File
@@ -0,0 +1,52 @@
/*
CONFIG where the API lives. Resolution order:
1. ?api=<url> query param on any page (session override)
2. localStorage (saved from the Settings dialog)
3. built-in default
*/
(function () {
'use strict';
const DEFAULT_BASE = 'https://bug-api-test.tuns.sh';
const STORAGE_KEY = 'streamreverse.apiBase.v1';
function normalize(value) {
let v = String(value || '').trim();
if (!v) return '';
if (!/^https?:\/\//i.test(v)) v = `https://${v}`;
return v.replace(/\/+$/, '');
}
function getBase() {
const fromQuery = SR.util.getParam('api');
if (fromQuery) return normalize(fromQuery);
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return normalize(stored);
} catch (e) {
/* private mode etc. */
}
return DEFAULT_BASE;
}
function setBase(value) {
const v = normalize(value);
try {
localStorage.setItem(STORAGE_KEY, v);
} catch (e) {
/* ignore */
}
return v;
}
function clearBase() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
/* ignore */
}
}
window.SR = window.SR || {};
SR.config = { DEFAULT_BASE, STORAGE_KEY, getBase, setBase, clearBase };
})();
+55
View File
@@ -0,0 +1,55 @@
/*
HOME hero with search + continue-watching row + trending rows.
*/
(function () {
'use strict';
// quick-search chips in the hero — edit freely
const QUICK_SEARCHES = ['Action', 'Comedy', 'Sci-Fi', 'Drama', 'Horror', 'Animation', 'Crime'];
SR.app.initChrome();
const sections = document.getElementById('sections');
const form = document.getElementById('heroForm');
const input = document.getElementById('heroInput');
if (form && input) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const q = input.value.trim();
location.href = q ? `search.html?q=${encodeURIComponent(q)}` : 'search.html';
});
}
const chips = document.getElementById('heroChips');
if (chips) {
for (const term of QUICK_SEARCHES) {
const a = document.createElement('a');
a.className = 'chip';
a.href = `search.html?q=${encodeURIComponent(term)}`;
a.textContent = term;
chips.appendChild(a);
}
}
const cont = SR.cards.continueRow();
if (cont) sections.appendChild(cont);
function appendRow(data, title) {
if (!data || !data.count) return;
sections.appendChild(SR.cards.row(title, data.results.map(SR.cards.normalizeItem)));
}
(async () => {
try {
appendRow(await SR.api.trending('movie', 'day'), 'Trending movies today');
} catch (e) {
SR.app.showError(e, 'could not load trending movies');
}
try {
appendRow(await SR.api.trending('tv', 'week'), 'Trending shows this week');
} catch (e) {
SR.app.showError(e, 'could not load trending shows');
}
})();
})();
+180
View File
@@ -0,0 +1,180 @@
/*
PLAYER plays a stream in a <video> element and supports switching
sources mid-watch without losing your place.
Pipeline per stream:
- hls.js for .m3u8 when available
- native HLS for Safari (canPlayType check)
- direct <video src> for mp4/webm
- everything else (DASH, mkv, magnet, ) friendly error
Every media + subtitle URL is routed through the API proxy.
On source switch we carry over currentTime, playbackRate, volume, muted
and the playing state, clamped to the new duration.
*/
(function () {
'use strict';
function create(video, handlers = {}) {
const onStatus = handlers.onStatus || (() => {});
const onError = handlers.onError || (() => {});
const onReady = handlers.onReady || (() => {});
let hls = null;
let token = 0; // guards against overlapping loads
let networkRetry = false;
function teardown() {
token++;
if (hls) {
try {
hls.destroy();
} catch (e) {
/* already destroyed */
}
hls = null;
}
for (const track of video.querySelectorAll('track')) track.remove();
video.removeAttribute('src');
try {
video.load();
} catch (e) {
/* ignore */
}
}
function capture() {
const v = video;
return {
time: isFinite(v.currentTime) && v.currentTime > 0 ? v.currentTime : 0,
rate: v.playbackRate || 1,
volume: v.volume,
muted: v.muted,
playing: !v.paused && !v.ended,
};
}
function restore(st) {
const v = video;
v.playbackRate = st.rate;
v.volume = st.volume;
v.muted = st.muted;
const seek = () => {
const d = v.duration;
let t = st.time;
if (isFinite(d) && d > 0) t = Math.min(t, Math.max(0, d - 5));
if (t > 10) v.currentTime = t;
if (st.playing) v.play().catch(() => {});
};
if (isFinite(v.duration) && v.duration > 0) seek();
else v.addEventListener('loadedmetadata', seek, { once: true });
}
function attachSubtitles(stream) {
for (const sub of stream.subtitles || []) {
let resolved;
try {
resolved = new URL(sub.url, stream.url).toString();
} catch (e) {
continue;
}
const track = document.createElement('track');
track.kind = 'subtitles';
track.srclang = sub.language || 'en';
track.label = sub.name || sub.language || 'Subtitles';
track.src = SR.api.proxyUrl(resolved, stream.headers);
video.appendChild(track);
}
}
function load(stream, opts = {}) {
const st = capture();
if (typeof opts.resumeAt === 'number' && opts.resumeAt > 10) st.time = opts.resumeAt;
teardown();
networkRetry = false;
const myToken = token;
const guarded = (fn) => () => {
if (myToken === token) fn();
};
const url = stream.url || '';
const isHls = stream.type === 'm3u8' || /\.m3u8($|\?)/i.test(url);
const isDash = stream.type === 'dash' || /\.mpd($|\?)/i.test(url);
const isDirect =
stream.type === 'mp4' ||
stream.type === 'webm' ||
/\.(mp4|m4v|webm)(\?|#|$)/i.test(url);
if (isDash) {
onError('DASH streams are not supported yet — pick another source.');
return;
}
if (!isHls && !isDirect) {
onError(`The browser can't play ${stream.type} files directly — pick an MP4 or HLS source.`);
return;
}
attachSubtitles(stream);
const proxied = SR.api.proxyUrl(url, stream.headers);
if (isHls && window.Hls && Hls.isSupported()) {
onStatus('Loading stream…');
const h = new Hls({ enableWorker: true });
hls = h;
h.loadSource(proxied);
h.attachMedia(video);
h.on(Hls.Events.MANIFEST_PARSED, guarded(() => {
restore(st);
onReady(st.time > 10 ? st.time : 0);
}));
h.on(Hls.Events.ERROR, (evt, data) => {
if (myToken !== token || !data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (!networkRetry) {
networkRetry = true;
setTimeout(() => {
networkRetry = false;
if (myToken === token && hls === h) h.startLoad();
}, 2000);
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
h.recoverMediaError();
} else {
onError('Playback failed: ' + (data.details || 'unknown stream error'));
teardown();
}
});
return;
}
// native HLS (Safari) or direct file
onStatus('Loading stream…');
video.src = proxied;
video.addEventListener(
'loadedmetadata',
guarded(() => {
restore(st);
onReady(st.time > 10 ? st.time : 0);
}),
{ once: true }
);
video.addEventListener(
'error',
guarded(() => {
onError('Could not load this stream — try another source.');
}),
{ once: true }
);
}
function destroy() {
teardown();
}
return { load, destroy };
}
window.SR = window.SR || {};
SR.player = { create };
})();
+88
View File
@@ -0,0 +1,88 @@
/*
SEARCH debounced live search with client-side type filter.
URL ?q= is kept in sync so results are shareable/bookmarkable.
*/
(function () {
'use strict';
SR.app.initChrome();
const input = document.getElementById('searchInput');
const chipsEl = document.getElementById('typeChips');
const resultsEl = document.getElementById('results');
const countEl = document.getElementById('searchCount');
const state = { type: 'all', results: [] };
function render() {
resultsEl.replaceChildren();
const filtered = state.type === 'all' ? state.results : state.results.filter((i) => i.type === state.type);
if (!state.results.length) {
countEl.textContent = '';
resultsEl.appendChild(
SR.cards.emptyState('Nothing found', 'Check the spelling or try a different name.')
);
return;
}
countEl.textContent = `${filtered.length} of ${state.results.length} result${state.results.length === 1 ? '' : 's'}`;
if (!filtered.length) {
resultsEl.appendChild(SR.cards.emptyState('No matches for this filter', 'Try the All filter, or a different search.'));
return;
}
resultsEl.appendChild(SR.cards.grid(filtered));
}
async function runSearch(q) {
q = (q || '').trim();
if (!q) {
state.results = [];
countEl.textContent = '';
resultsEl.replaceChildren(SR.cards.emptyState('Search the catalog', 'Type a movie or show name above.'));
return;
}
countEl.textContent = 'Searching…';
try {
const data = await SR.api.search(q);
state.results = data.results.map(SR.cards.normalizeItem);
render();
} catch (e) {
SR.app.showError(e, 'search failed');
countEl.textContent = '';
}
}
const LABELS = { all: 'All', movie: 'Movies', tv: 'TV' };
for (const t of ['all', 'movie', 'tv']) {
const b = document.createElement('button');
b.type = 'button';
b.className = t === 'all' ? 'chip chip--active' : 'chip';
b.textContent = LABELS[t];
b.addEventListener('click', () => {
state.type = t;
for (const c of chipsEl.querySelectorAll('.chip')) c.classList.remove('chip--active');
b.classList.add('chip--active');
render();
});
chipsEl.appendChild(b);
}
input.addEventListener(
'input',
SR.util.debounce(() => {
const q = input.value.trim();
history.replaceState(null, '', q ? `?q=${encodeURIComponent(q)}` : 'search.html');
runSearch(q);
}, 450)
);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
runSearch(input.value.trim());
}
});
const initialQ = SR.util.getParam('q') || '';
input.value = initialQ;
if (initialQ) runSearch(initialQ);
else input.focus();
})();
+87
View File
@@ -0,0 +1,87 @@
/*
STORAGE resume points, kept in localStorage (survives restarts, no size
worries like cookies, no per-request overhead).
A "record" looks like:
{
key: "m:603" | "t:1396:s1:e2",
type: "movie" | "tv",
tmdbId: 1396,
season: 1, // tv only
episode: 2, // tv only
show: "Show name",
title: "Episode name", // tv only
poster: "https://…/w342/…",
t: 934.2, // seconds played
d: 2710.5, // duration seconds
at: 1712345678901,
provider: "cineby"
}
One record per (movie) or (show, season, episode). The home page collapses
to the most recent record per show.
*/
(function () {
'use strict';
const KEY = 'streamreverse.resume.v1';
const MAX_RECORDS = 200;
function readAll() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY) || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (e) {
return {};
}
}
function writeAll(all) {
try {
localStorage.setItem(KEY, JSON.stringify(all));
} catch (e) {
/* storage full/blocked — resume just won't persist */
}
}
function keyFor({ type, tmdbId, season, episode }) {
return type === 'tv' ? `t:${tmdbId}:s${season}:e${episode}` : `m:${tmdbId}`;
}
function get(key) {
return readAll()[key] || null;
}
function save(record) {
const all = readAll();
all[record.key] = record;
const keys = Object.keys(all).sort((a, b) => (all[a].at || 0) - (all[b].at || 0));
while (keys.length > MAX_RECORDS) delete all[keys.shift()];
writeAll(all);
}
function remove(key) {
const all = readAll();
if (all[key]) {
delete all[key];
writeAll(all);
}
}
function list() {
return Object.values(readAll());
}
// one entry per movie/show: the most recently watched record wins
function latestPerShow() {
const byShow = new Map();
for (const rec of list().sort((a, b) => (a.at || 0) - (b.at || 0))) {
const showKey = rec.type === 'tv' ? `t:${rec.tmdbId}` : `m:${rec.tmdbId}`;
byShow.set(showKey, rec);
}
return [...byShow.values()].sort((a, b) => (b.at || 0) - (a.at || 0));
}
window.SR = window.SR || {};
SR.storage = { KEY, keyFor, get, save, remove, list, latestPerShow };
})();
+55
View File
@@ -0,0 +1,55 @@
/*
UTIL small shared helpers. No dependencies.
*/
(function () {
'use strict';
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getParam(name) {
return new URLSearchParams(location.search).get(name);
}
// 95 -> "1:35", 3675 -> "1:01:15"
function formatSeconds(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const mm = h ? String(m).padStart(2, '0') : String(m);
const ss = String(s).padStart(2, '0');
return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
// 141 -> "2h 21m"
function runtimeLabel(mins) {
if (!mins || mins <= 0) return '';
const h = Math.floor(mins / 60);
const m = mins % 60;
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
}
function debounce(fn, wait) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
// resize a full TMDB image URL (…/t/p/<size>/path) to another size
function tmdbResize(url, size) {
if (!url) return '';
return url.replace(/\/t\/p\/[A-Za-z0-9._-]+/, `/t/p/${size}`);
}
window.SR = window.SR || {};
SR.util = { escapeHtml, getParam, formatSeconds, runtimeLabel, debounce, tmdbResize };
})();
+366
View File
@@ -0,0 +1,366 @@
/*
WATCH hero, player, source list (switch mid-watch), episodes.
URL params: ?id=<tmdb id>&type=<movie|tv>
TV adds &s=<season>&e=<episode> as playback progresses.
*/
(function () {
'use strict';
const params = new URLSearchParams(location.search);
const id = (params.get('id') || '').trim();
const type = params.get('type') === 'tv' ? 'tv' : 'movie';
SR.app.initChrome();
const content = document.getElementById('content');
if (!id || !/^\d+$/.test(id)) {
content.replaceChildren(SR.cards.emptyState('Missing ?id=', 'This page needs a TMDB id, e.g. watch.html?id=1396&type=tv'));
return;
}
const video = document.getElementById('video');
const statusEl = document.getElementById('playerStatus');
const sourceLabel = document.getElementById('sourceLabel');
const resumeNote = document.getElementById('resumeNote');
const sourcesPanel = document.getElementById('sourcesPanel');
const changeSourceBtn = document.getElementById('changeSourceBtn');
const episodeSection = document.getElementById('episodes');
const seasonSelect = document.getElementById('seasonSelect');
const episodeGrid = document.getElementById('episodeGrid');
const prevBtn = document.getElementById('prevEpisode');
const nextBtn = document.getElementById('nextEpisode');
const state = {
details: null,
seasons: [],
episodes: [],
streams: [],
sourceIndex: -1,
season: null,
episode: null,
};
/* ---- status overlay ------------------------------------------------------ */
function showStatus(msg, isError) {
statusEl.hidden = false;
statusEl.innerHTML = isError
? `<h3>Playback problem</h3><p>${SR.util.escapeHtml(msg)}</p>`
: `<div class="spinner"></div><p>${SR.util.escapeHtml(msg)}</p>`;
}
function hideStatus() {
statusEl.hidden = true;
}
/* ---- player --------------------------------------------------------------- */
const player = SR.player.create(video, {
onStatus: (msg) => showStatus(msg),
onReady: (resumeAt) => {
hideStatus();
if (resumeAt > 10) resumeNote.textContent = `Resuming at ${SR.util.formatSeconds(resumeAt)}`;
},
onError: (msg) => {
showStatus(msg, true);
SR.app.toast(msg, true);
},
});
/* ---- hero ------------------------------------------------------------------ */
function renderHero(d) {
document.title = `${d.name || d.title} — bug.tv`;
const isTv = type === 'tv';
const year = String((isTv ? d.first_air_date : d.release_date) || '').slice(0, 4);
const facts = [
isTv ? `${d.number_of_seasons || 0} seasons` : SR.util.runtimeLabel(d.runtime),
year,
]
.filter(Boolean)
.map(SR.util.escapeHtml)
.join('<span>·</span>');
const rating = d.vote_average
? `<span class="watch-hero__rating">★ ${Number(d.vote_average).toFixed(1)}</span>`
: '';
const genres = (d.genres || [])
.map((g) => `<span class="tag">${SR.util.escapeHtml(g.name)}</span>`)
.join('');
const poster = SR.util.tmdbResize(d.poster_url, 'w500');
const backdrop = SR.util.tmdbResize(d.backdrop_url, 'w1280');
document.getElementById('hero').innerHTML = `
${backdrop ? `<div class="watch-hero__backdrop"><img src="${backdrop}" alt=""></div>` : ''}
${poster ? `<div class="watch-hero__poster"><img src="${poster}" alt=""></div>` : ''}
<div class="watch-hero__body">
<h1 class="watch-hero__title">${SR.util.escapeHtml(d.name || d.title)}</h1>
<div class="watch-hero__facts">${rating}${rating && facts ? '<span>·</span>' : ''}${facts}</div>
${genres ? `<div class="watch-hero__genres">${genres}</div>` : ''}
${d.overview ? `<p class="watch-hero__overview">${SR.util.escapeHtml(d.overview)}</p>` : ''}
</div>`;
}
/* ---- url -------------------------------------------------------------------- */
function syncUrl() {
const p = new URLSearchParams();
p.set('id', id);
p.set('type', type);
if (type === 'tv') {
if (state.season != null) p.set('s', state.season);
if (state.episode != null) p.set('e', state.episode);
}
history.replaceState(null, '', `?${p.toString()}`);
}
/* ---- streams ------------------------------------------------------------------ */
function keyFor() {
return SR.storage.keyFor({ type, tmdbId: Number(id), season: state.season, episode: state.episode });
}
async function loadStreams() {
const query = { tmdb: id, type };
if (type === 'tv') {
if (state.season == null || state.episode == null) return;
query.season = state.season;
query.episode = state.episode;
}
sourcesPanel.hidden = true;
changeSourceBtn.textContent = 'Change source';
showStatus('Finding streams…');
sourceLabel.textContent = '';
resumeNote.textContent = '';
try {
const data = await SR.api.streams(query);
state.streams = data.streams || [];
renderSources();
changeSourceBtn.hidden = state.streams.length === 0;
if (state.streams.length) playSource(0);
else showStatus('No streams found for this title.', true);
} catch (e) {
SR.app.showError(e, 'stream lookup failed');
showStatus('Stream lookup failed.', true);
}
}
function renderSources() {
const list = document.getElementById('sourceList');
list.replaceChildren();
state.streams.forEach((s, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = i === state.sourceIndex ? 'source-item source-item--active' : 'source-item';
const meta = [s.provider, s.quality, s.audio].filter(Boolean).join(' · ');
b.innerHTML = `
<span class="source-item__rank">${i + 1}</span>
<span class="source-item__name">${SR.util.escapeHtml(s.name)}</span>
<span class="source-item__meta">${SR.util.escapeHtml(meta)}</span>`;
b.addEventListener('click', () => playSource(i));
list.appendChild(b);
});
document.getElementById('sourcesCount').textContent =
`${state.streams.length} source${state.streams.length === 1 ? '' : 's'}`;
}
function playSource(i) {
const s = state.streams[i];
if (!s) return;
state.sourceIndex = i;
renderSources();
sourceLabel.textContent = `${s.name}${[s.quality, s.provider].filter(Boolean).join(', ')}`;
const rec = SR.storage.get(keyFor());
player.load(s, { resumeAt: rec && rec.t > 10 ? rec.t : null });
}
changeSourceBtn.addEventListener('click', () => {
sourcesPanel.hidden = !sourcesPanel;
changeSourceBtn.textContent = sourcesPanel.hidden ? 'Change source' : 'Hide sources';
});
/* ---- resume persistence ---------------------------------------------------------- */
let lastPersist = 0;
function persistProgress(force) {
const d = video.duration;
if (!d || !isFinite(d)) return;
const t = video.currentTime;
if (t < 10) return;
const key = keyFor();
if (t >= d - 15 || t / d >= 0.95) {
SR.storage.remove(key);
return;
}
const now = Date.now();
if (!force && now - lastPersist < 3000) return;
lastPersist = now;
const ep = state.episodes.find((e) => e.episode_number === state.episode);
SR.storage.save({
key,
type,
tmdbId: Number(id),
season: state.season,
episode: state.episode,
show: state.details ? state.details.name || state.details.title : '',
title: ep ? ep.name || '' : '',
poster: state.details ? SR.util.tmdbResize(state.details.poster_url, 'w342') : '',
t,
d,
at: now,
provider: state.streams[state.sourceIndex] ? state.streams[state.sourceIndex].provider : '',
});
}
video.addEventListener('timeupdate', () => persistProgress(false));
video.addEventListener('pause', () => persistProgress(true));
video.addEventListener('seeked', () => persistProgress(true));
video.addEventListener('ended', () => SR.storage.remove(keyFor()));
window.addEventListener('pagehide', () => persistProgress(true));
document.addEventListener('visibilitychange', () => {
if (document.hidden) persistProgress(true);
});
/* ---- episodes ------------------------------------------------------------------------ */
async function loadSeasons() {
const data = await SR.api.seasons(id);
state.seasons = data.seasons || [];
for (const s of state.seasons) {
const opt = document.createElement('option');
opt.value = s.season_number;
opt.textContent = `${s.name} (${s.episode_count})`;
seasonSelect.appendChild(opt);
}
}
function showGridSpinner() {
episodeGrid.replaceChildren();
const cell = document.createElement('div');
cell.className = 'loading-cell';
cell.innerHTML = '<div class="spinner"></div>';
episodeGrid.appendChild(cell);
}
function renderEpisodes() {
episodeGrid.replaceChildren();
if (!state.episodes.length) {
const empty = SR.cards.emptyState('No episodes listed');
empty.style.gridColumn = '1 / -1';
episodeGrid.appendChild(empty);
return;
}
for (const ep of state.episodes) {
const b = document.createElement('button');
b.type = 'button';
b.className = ep.episode_number === state.episode ? 'episode episode--current' : 'episode';
const date = ep.air_date ? ` · ${SR.util.escapeHtml(ep.air_date)}` : '';
b.innerHTML = `
<span class="episode__thumb"><span class="episode__play"></span></span>
<span class="episode__body">
<span class="episode__num">S${state.season} · E${ep.episode_number}${date}</span>
<span class="episode__title">${SR.util.escapeHtml(ep.name || `Episode ${ep.episode_number}`)}</span>
</span>`;
b.addEventListener('click', () => playEpisode(ep.episode_number));
episodeGrid.appendChild(b);
}
}
async function loadEpisodes(season, autoplayEpisode) {
state.season = season;
seasonSelect.value = String(season);
showGridSpinner();
const data = await SR.api.episodes(id, season);
state.episodes = data.episodes || [];
renderEpisodes();
syncUrl();
if (autoplayEpisode && state.episodes.some((e) => e.episode_number === autoplayEpisode)) {
playEpisode(autoplayEpisode);
}
}
function playEpisode(n) {
state.episode = n;
syncUrl();
renderEpisodes();
loadStreams();
document.getElementById('playerSection').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
seasonSelect.addEventListener('change', async () => {
const season = parseInt(seasonSelect.value, 10);
if (!season) return;
try {
await loadEpisodes(season);
} catch (e) {
SR.app.showError(e, 'failed to load episodes');
}
});
async function stepEpisode(delta) {
if (type !== 'tv') return;
const idx = state.episodes.findIndex((e) => e.episode_number === state.episode);
if (idx >= 0 && idx + delta >= 0 && idx + delta < state.episodes.length) {
playEpisode(state.episodes[idx + delta].episode_number);
return;
}
const sIdx = state.seasons.findIndex((s) => s.season_number === state.season);
if (sIdx < 0 || sIdx + delta < 0 || sIdx + delta >= state.seasons.length) return;
try {
await loadEpisodes(state.seasons[sIdx + delta].season_number);
const first = state.episodes[0];
if (first) playEpisode(first.episode_number);
} catch (e) {
SR.app.showError(e, 'failed to load episodes');
}
}
prevBtn.addEventListener('click', () => stepEpisode(-1));
nextBtn.addEventListener('click', () => stepEpisode(1));
/* ---- init ------------------------------------------------------------------------------ */
(async function init() {
let details;
try {
details = await SR.api.details(type, id);
} catch (e) {
SR.app.showError(e, 'failed to load this title');
showStatus('Could not load this title.', true);
return;
}
state.details = details;
renderHero(details);
if (type !== 'tv') {
loadStreams();
return;
}
episodeSection.hidden = false;
showStatus('Pick an episode to start.');
try {
await loadSeasons();
} catch (e) {
SR.app.showError(e, 'failed to load seasons');
return;
}
if (!state.seasons.length) {
showStatus('No seasons found for this show.', true);
return;
}
const sParam = parseInt(params.get('s'), 10);
const eParam = parseInt(params.get('e'), 10);
const season = state.seasons.some((s) => s.season_number === sParam)
? sParam
: state.seasons[0].season_number;
try {
await loadEpisodes(season, eParam || null);
} catch (e) {
SR.app.showError(e, 'failed to load episodes');
showStatus('Failed to load episodes.', true);
}
})();
})();
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bug.tv — search</title>
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/pages.css">
</head>
<body>
<header class="site-header">
<div class="container site-header__inner">
<a class="brand" href="index.html">bug<span>.tv</span></a>
<a class="site-header__nav" href="index.html">Home</a>
<a class="site-header__nav" href="search.html">Search</a>
<span class="site-header__spacer"></span>
<button class="btn btn--ghost btn--sm" id="settingsBtn" type="button">Settings</button>
</div>
</header>
<main class="container page" id="content">
<div class="search-bar">
<form class="search-bar__form" role="search" onsubmit="return false">
<input class="field search-bar__input" id="searchInput" type="search" placeholder="Search movies and shows…" autocomplete="off" spellcheck="false">
</form>
</div>
<div class="search-toolbar">
<div class="chips" id="typeChips"></div>
<span class="search-count" id="searchCount"></span>
</div>
<div id="results"></div>
</main>
<footer class="site-footer">
<div class="container">
<span>bug.tv</span>
<span>API: <a id="apiBaseLabel" href="#" target="_blank" rel="noopener"></a></span>
</div>
</footer>
<div class="modal-overlay" id="settingsModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<div class="modal__head">
<h2 class="modal__title" id="settingsTitle">Settings</h2>
<button class="btn btn--ghost btn--sm" id="settingsClose" type="button">Close</button>
</div>
<div class="modal__body">
<div>
<label class="field-label" for="apiBaseInput">API base URL</label>
<input class="field" id="apiBaseInput" type="url" placeholder="https://…">
<p class="modal__hint">
All requests go to this API. You can also override it for one session
by adding <code>?api=https://…</code> to any page URL.
</p>
</div>
</div>
<div class="modal__actions">
<button class="btn" id="apiBaseReset" type="button">Reset to default</button>
<button class="btn btn--primary" id="apiBaseSave" type="button">Save</button>
</div>
</div>
</div>
<script src="js/util.js"></script>
<script src="js/config.js"></script>
<script src="js/api.js"></script>
<script src="js/storage.js"></script>
<script src="js/cards.js"></script>
<script src="js/app.js"></script>
<script src="js/search.js"></script>
</body>
</html>
+4
View File
File diff suppressed because one or more lines are too long
+99
View File
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bug.tv — watch</title>
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/pages.css">
</head>
<body>
<header class="site-header">
<div class="container site-header__inner">
<a class="brand" href="index.html">bug<span>.tv</span></a>
<a class="site-header__nav" href="index.html">Home</a>
<a class="site-header__nav" href="search.html">Search</a>
<span class="site-header__spacer"></span>
<button class="btn btn--ghost btn--sm" id="settingsBtn" type="button">Settings</button>
</div>
</header>
<main class="container page" id="content">
<section class="watch-hero" id="hero" aria-busy="true"></section>
<section id="playerSection">
<div class="player-wrap">
<video id="video" controls playsinline></video>
<div class="player-status" id="playerStatus">
<div class="spinner"></div>
<p>Getting ready…</p>
</div>
</div>
<div class="watch-toolbar">
<span class="watch-toolbar__source" id="sourceLabel"></span>
<span id="resumeNote"></span>
<span class="watch-toolbar__spacer"></span>
<button class="btn btn--sm" id="changeSourceBtn" type="button" hidden>Change source</button>
</div>
<div class="sources" id="sourcesPanel" hidden>
<div class="sources__head">Sources <span id="sourcesCount"></span></div>
<div id="sourceList"></div>
</div>
</section>
<section class="episodes" id="episodes" hidden>
<div class="episodes__toolbar">
<h2 class="episodes__title">Episodes</h2>
<select class="field" id="seasonSelect" aria-label="Season"></select>
<button class="btn btn--sm" id="prevEpisode" type="button">Previous</button>
<button class="btn btn--sm" id="nextEpisode" type="button">Next</button>
</div>
<div class="episode-grid" id="episodeGrid"></div>
</section>
</main>
<footer class="site-footer">
<div class="container">
<span>bug.tv</span>
<span>API: <a id="apiBaseLabel" href="#" target="_blank" rel="noopener"></a></span>
</div>
</footer>
<div class="modal-overlay" id="settingsModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<div class="modal__head">
<h2 class="modal__title" id="settingsTitle">Settings</h2>
<button class="btn btn--ghost btn--sm" id="settingsClose" type="button">Close</button>
</div>
<div class="modal__body">
<div>
<label class="field-label" for="apiBaseInput">API base URL</label>
<input class="field" id="apiBaseInput" type="url" placeholder="https://…">
<p class="modal__hint">
All requests go to this API. You can also override it for one session
by adding <code>?api=https://…</code> to any page URL.
</p>
</div>
</div>
<div class="modal__actions">
<button class="btn" id="apiBaseReset" type="button">Reset to default</button>
<button class="btn btn--primary" id="apiBaseSave" type="button">Save</button>
</div>
</div>
</div>
<script src="vendor/hls.min.js"></script>
<script src="js/util.js"></script>
<script src="js/config.js"></script>
<script src="js/api.js"></script>
<script src="js/storage.js"></script>
<script src="js/cards.js"></script>
<script src="js/app.js"></script>
<script src="js/player.js"></script>
<script src="js/watch.js"></script>
</body>
</html>