c58fa9591b
A retro CRT-themed web TV that channel-surfs live HLS streams through a security-hardened, same-origin Rust relay. Front end (vanilla JS + HLS.js, no framework/build step): - SourceProvider abstraction + canonical Channel/Program model so public streams and future self-hosted media share one UI (MockProvider built first to keep the UI provably source-agnostic; IptvOrgProvider for live data) - Red/black phosphor CRT theme, virtualized 1994-style guide (The Almanac), cable-box zapping with 7-segment OSD, scrying-static transitions - Theater/Dim/Ambient immersion modes, dead-stream "orb clouds over" handling - The Lexicon in-world naming with mundane-mode; full a11y + reduced-motion - HLS.js pinned + Subresource-Integrity verified Rust relay (hyper 1.x + tokio-rustls/ring + hickory-resolver): - Serves the SPA and proxies HLS from one origin (no CORS, untainted canvas) - SSRF defense: resolve -> reject private/loopback/link-local/metadata IPs -> connect to the validated IP (no re-resolve, defeats DNS-rebind), fail-closed - HTTPS-only egress, re-validated redirects, manifest URL rewrite, size/ concurrency caps, strict CSP + security headers, path-traversal guard - /metrics observability + structured logs (host only, never raw URLs) Verified: all SSRF probes rejected, CSP clean, real Apple HLS manifest rewritten, ~39k iptv-org channels fetched end-to-end, tests green, zero warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
4.8 KiB
JavaScript
118 lines
4.8 KiB
JavaScript
// mock.js — MockProvider. Built FIRST, on purpose.
|
|
//
|
|
// Proves the UI is fully source-agnostic with ZERO network: fake channels, a
|
|
// rolling "scheduled-live" EPG timeline (so now/next + the guide populate), and
|
|
// a couple of real CORS-clean public TEST streams so playback can be exercised
|
|
// standalone (no relay needed). One channel points at a dead URL to exercise the
|
|
// "the orb clouds over" no-signal path.
|
|
|
|
import { SourceProvider } from './source-provider.js';
|
|
|
|
/** Tiny inline-SVG logo so channels render with no network. */
|
|
function logo(initials, accent = '#ff2b2b') {
|
|
const svg =
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="54">` +
|
|
`<rect width="96" height="54" fill="#0a0000"/>` +
|
|
`<rect x="1" y="1" width="94" height="52" fill="none" stroke="${accent}" stroke-width="1.5"/>` +
|
|
`<text x="48" y="34" font-family="monospace" font-size="22" font-weight="bold" ` +
|
|
`text-anchor="middle" fill="${accent}">${initials}</text></svg>`;
|
|
return 'data:image/svg+xml,' + encodeURIComponent(svg);
|
|
}
|
|
|
|
// CORS-clean public test streams (HLS). Stable, broadcaster-hosted demos.
|
|
const TEST_STREAMS = {
|
|
bipbop: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8',
|
|
mux: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
|
|
bbb: 'https://test-streams.mux.dev/test_001/stream.m3u8',
|
|
dead: 'https://stream.invalid.example/offline.m3u8',
|
|
};
|
|
|
|
const TITLE_POOL = {
|
|
'Arcane Cinema': ['The Reanimator', 'Hex of the Hollow', 'Salt & Sigil', 'Midnight Grimoire',
|
|
'The Pondering Hour', 'Witchlight', 'The Veil Lifts', 'Candle & Bone'],
|
|
'Hack the Planet': ['Packets at Dawn', 'Root of All Evil', 'The Blue Box', 'Zero Cool',
|
|
'Buffer Overflow', 'The Last Daemon', 'Kernel Panic', 'Shell Games'],
|
|
'Old Signals': ['Test Pattern Theatre', 'Static & Snow', 'The Late Late Broadcast',
|
|
'Color Bars', 'Sign-Off Sermon', 'Dead Air'],
|
|
'The Almanac': ['What Fate Foretells', 'Stars & Portents', 'The Long Now', 'Tomorrow, Maybe'],
|
|
};
|
|
|
|
function buildSchedule(channelId, group, now) {
|
|
/** @type {import('./source-provider.js').Program[]} */
|
|
const programs = [];
|
|
const titles = TITLE_POOL[group] || TITLE_POOL['Old Signals'];
|
|
// Deterministic-ish per channel so reloads look stable.
|
|
let seed = [...channelId].reduce((a, c) => a + c.charCodeAt(0), 0);
|
|
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
|
// Start two hours back, fill ~10h.
|
|
let t = now - 2 * 3600 * 1000;
|
|
t = t - (t % (30 * 60 * 1000)); // align to :00/:30
|
|
const end = now + 8 * 3600 * 1000;
|
|
let i = Math.floor(rand() * titles.length);
|
|
while (t < end) {
|
|
const slots = 1 + Math.floor(rand() * 3); // 30/60/90 min
|
|
const dur = slots * 30 * 60 * 1000;
|
|
programs.push({
|
|
channelId,
|
|
title: titles[i % titles.length],
|
|
desc: 'A scheduled broadcast. Tuning in drops you mid-program, live — just like real TV.',
|
|
start: t,
|
|
stop: t + dur,
|
|
});
|
|
t += dur;
|
|
i++;
|
|
}
|
|
return programs;
|
|
}
|
|
|
|
const CHANNELS = [
|
|
{ n: 2, name: 'Arcane Cinema', group: 'Arcane Cinema', stream: 'mux', init: 'AC' },
|
|
{ n: 3, name: 'Hack the Planet',group: 'Hack the Planet', stream: 'bbb', init: 'HP' },
|
|
{ n: 4, name: 'Bip-Bop Test', group: 'Old Signals', stream: 'bipbop', init: 'BB' },
|
|
{ n: 5, name: 'The Late Show', group: 'Old Signals', stream: 'mux', init: 'LS' },
|
|
{ n: 6, name: 'Dead Air', group: 'Old Signals', stream: 'dead', init: 'XX' },
|
|
{ n: 7, name: 'The Almanac', group: 'The Almanac', stream: 'bbb', init: 'AL' },
|
|
];
|
|
|
|
export class MockProvider extends SourceProvider {
|
|
get id() { return 'mock'; }
|
|
get name() { return 'The Mock Realm'; }
|
|
get supportsGuides() { return true; }
|
|
|
|
async listChannels() {
|
|
return CHANNELS.map((c) => ({
|
|
id: `mock:${c.n}`,
|
|
source: 'mock',
|
|
name: c.name,
|
|
number: c.n,
|
|
logo: logo(c.init),
|
|
group: c.group,
|
|
country: 'XX',
|
|
language: 'eng',
|
|
kind: 'live',
|
|
streamUrls: [TEST_STREAMS[c.stream]],
|
|
guideId: `mock:${c.n}`,
|
|
}));
|
|
}
|
|
|
|
async getStreamHandle(channelId) {
|
|
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
|
|
if (!c) return null;
|
|
// Mock returns direct URLs (test streams are CORS-clean) so the UI runs standalone.
|
|
return { url: TEST_STREAMS[c.stream], protocol: 'hls' };
|
|
}
|
|
|
|
async getPrograms(channelId, window) {
|
|
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
|
|
if (!c) return [];
|
|
const all = buildSchedule(channelId, c.group, Date.now());
|
|
return all.filter((p) => p.stop > window.from && p.start < window.to);
|
|
}
|
|
|
|
async healthCheck(channelId) {
|
|
const c = CHANNELS.find((x) => `mock:${x.n}` === channelId);
|
|
if (c && c.stream === 'dead') return { ok: false, error: 'mock offline channel' };
|
|
return { ok: true };
|
|
}
|
|
}
|