Files
Diablo_Rain a1e3e6852f feat: Phase 1.5 — health prober, Oracle's Memory, off-thread EPG, PWA
Resilience + scale pass on top of the v1 core.

Stream health prober:
- New SSRF-validated /probe endpoint (HEAD -> ranged-GET fallback, 6s timeout,
  5-min TTL cache, concurrency-capped, no-store) + orb_probe_total metrics
- Front-end health.js: lazy bounded-concurrency probing (current + sampled
  channels, not the whole catalog), LIVE/DEGRADED/DEAD scoring, guide skull
  glyph, instant no-signal on zap to a known-dead channel

Oracle's Memory (memory.js):
- Session resurrection (last channel, volume, mute, theater/dim/ambient)
- Exponential-decay per-channel uptime history; pinning; dead-channel auto-skip

Off-thread XMLTV:
- lib/xmltv-parse.js (pure, hand-rolled scanner, no DOMParser/regex backtracking)
  run in epg-worker.js module worker; wired into the iptv-org provider
- Unit-tested in node: entity decode, TZ offsets, 20k programmes off-thread

Service worker / PWA:
- manifest.webmanifest + orb icons; sw.js (cache-first shell + hls.js,
  stale-while-revalidate for JSON-over-relay, never caches streams)
- CSP gains worker-src + manifest-src; relay sends no-store on /relay + /probe
  so the SW is the sole metadata freshness authority; webmanifest MIME fix

Docs: README de-emojified (title only) and reframed as a peer parallel build.

Verified: SSRF probes blocked, legit probe + cache, new headers present,
PWA assets served, parser tests 8/8, JS syntax clean, zero Rust warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:25:55 -04:00

119 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]],
probeUrls: [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 };
}
}