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>
186 lines
6.2 KiB
JavaScript
186 lines
6.2 KiB
JavaScript
// source-provider.js — the load-bearing abstraction.
|
|
//
|
|
// A channel is "a named thing on a guide that resolves to an HLS stream at a
|
|
// point in time." Public iptv-org streams and your future self-hosted media are
|
|
// just different SourceProviders feeding the SAME UI. Build providers against
|
|
// this contract; the UI (guide/zapper/player) only ever talks to the aggregator.
|
|
|
|
/**
|
|
* @typedef {Object} Channel
|
|
* @property {string} id Namespaced id, "source:rawId" (e.g. "iptv-org:CNN.us").
|
|
* @property {string} source Provider id that owns this channel.
|
|
* @property {string} name Display name (UNTRUSTED — render as textContent only).
|
|
* @property {number} [number] Assigned tuning number (filled by the aggregator).
|
|
* @property {string} [logo] Logo URL (UNTRUSTED — scheme-allowlisted before use).
|
|
* @property {string} [group] Category / group-title.
|
|
* @property {string} [country]
|
|
* @property {string} [language]
|
|
* @property {'live'|'vod'} kind Always 'live' for Phase 1; self-hosted "channels" are also 'live'.
|
|
* @property {string[]} streamUrls One or more upstream HLS URLs (primary + fallbacks).
|
|
* @property {string} [guideId] Key used to match EPG/program data.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} Program
|
|
* @property {string} channelId
|
|
* @property {string} title UNTRUSTED — textContent only.
|
|
* @property {string} [desc]
|
|
* @property {number} start Epoch ms (inclusive).
|
|
* @property {number} stop Epoch ms (exclusive).
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} StreamHandle
|
|
* @property {string} url Playable URL handed to HLS.js (may be relay-wrapped).
|
|
* @property {'hls'} protocol
|
|
* @property {Object<string,string>} [headers]
|
|
*/
|
|
|
|
/**
|
|
* The contract every source implements. Extend this or just match the shape.
|
|
* @abstract
|
|
*/
|
|
export class SourceProvider {
|
|
/** @type {string} stable namespace, e.g. "iptv-org" */
|
|
get id() { throw new Error('provider must define id'); }
|
|
/** @type {string} human label */
|
|
get name() { return this.id; }
|
|
/** @type {boolean} */
|
|
get supportsGuides() { return false; }
|
|
|
|
/** @returns {Promise<Channel[]>} */
|
|
async listChannels() { throw new Error('not implemented'); }
|
|
|
|
/**
|
|
* Resolve a channel to something HLS.js can play (relay-wrapped if needed).
|
|
* @param {string} _channelId
|
|
* @returns {Promise<StreamHandle|null>}
|
|
*/
|
|
async getStreamHandle(_channelId) { throw new Error('not implemented'); }
|
|
|
|
/**
|
|
* EPG slice for a time window. Default: none.
|
|
* @param {string} _channelId
|
|
* @param {{from:number,to:number}} _window
|
|
* @returns {Promise<Program[]>}
|
|
*/
|
|
async getPrograms(_channelId, _window) { return []; }
|
|
|
|
/**
|
|
* Optional liveness probe.
|
|
* @param {string} _channelId
|
|
* @returns {Promise<{ok:boolean, error?:string}>}
|
|
*/
|
|
async healthCheck(_channelId) { return { ok: true }; }
|
|
}
|
|
|
|
/**
|
|
* Merges channels from many providers behind one interface and routes
|
|
* per-channel calls back to the owning provider by source-prefix.
|
|
*/
|
|
export class ChannelAggregator {
|
|
constructor() {
|
|
/** @type {Map<string, SourceProvider>} */
|
|
this.providers = new Map();
|
|
/** @type {Channel[]} */
|
|
this._channels = [];
|
|
/** @type {Map<string, Channel>} */
|
|
this._byId = new Map();
|
|
}
|
|
|
|
/** @param {SourceProvider} provider */
|
|
register(provider) {
|
|
this.providers.set(provider.id, provider);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Load + merge all providers. De-dups by id, assigns sequential tuning numbers.
|
|
* @returns {Promise<Channel[]>}
|
|
*/
|
|
async listAllChannels() {
|
|
const lists = await Promise.all(
|
|
[...this.providers.values()].map(async (p) => {
|
|
try { return await p.listChannels(); }
|
|
catch (e) { console.warn(`[aggregator] provider ${p.id} failed:`, e); return []; }
|
|
}),
|
|
);
|
|
const seen = new Set();
|
|
/** @type {Channel[]} */
|
|
const merged = [];
|
|
for (const list of lists) {
|
|
for (const ch of list) {
|
|
if (!ch || !ch.id || seen.has(ch.id)) continue;
|
|
seen.add(ch.id);
|
|
merged.push(ch);
|
|
}
|
|
}
|
|
// Respect any explicit number, otherwise assign by order starting at 2 (channel 1 reserved).
|
|
let next = 2;
|
|
const used = new Set(merged.filter((c) => Number.isFinite(c.number)).map((c) => c.number));
|
|
for (const ch of merged) {
|
|
if (!Number.isFinite(ch.number)) {
|
|
while (used.has(next)) next++;
|
|
ch.number = next;
|
|
used.add(next);
|
|
}
|
|
}
|
|
merged.sort((a, b) => a.number - b.number);
|
|
this._channels = merged;
|
|
this._byId = new Map(merged.map((c) => [c.id, c]));
|
|
return merged;
|
|
}
|
|
|
|
/** @returns {Channel[]} */
|
|
get channels() { return this._channels; }
|
|
|
|
/** @param {string} id @returns {Channel|undefined} */
|
|
getChannel(id) { return this._byId.get(id); }
|
|
|
|
/** @param {number} number @returns {Channel|undefined} */
|
|
getChannelByNumber(number) { return this._channels.find((c) => c.number === number); }
|
|
|
|
/** @param {string} channelId @returns {SourceProvider|undefined} */
|
|
_ownerOf(channelId) {
|
|
const source = channelId.split(':', 1)[0];
|
|
return this.providers.get(source);
|
|
}
|
|
|
|
/** @param {string} channelId @returns {Promise<StreamHandle|null>} */
|
|
async getStreamHandle(channelId) {
|
|
const owner = this._ownerOf(channelId);
|
|
if (!owner) return null;
|
|
return owner.getStreamHandle(channelId);
|
|
}
|
|
|
|
/**
|
|
* @param {string} channelId
|
|
* @param {{from:number,to:number}} window
|
|
* @returns {Promise<Program[]>}
|
|
*/
|
|
async getPrograms(channelId, window) {
|
|
const owner = this._ownerOf(channelId);
|
|
if (!owner) return [];
|
|
try { return await owner.getPrograms(channelId, window); }
|
|
catch { return []; }
|
|
}
|
|
|
|
/**
|
|
* now/next helper used by the OSD banner and guide.
|
|
* @param {string} channelId
|
|
* @param {number} [at] epoch ms (default: now)
|
|
* @returns {Promise<{now: Program|null, next: Program|null}>}
|
|
*/
|
|
async nowNext(channelId, at = Date.now()) {
|
|
const horizon = at + 6 * 3600 * 1000;
|
|
const programs = await this.getPrograms(channelId, { from: at - 3600 * 1000, to: horizon });
|
|
programs.sort((a, b) => a.start - b.start);
|
|
let now = null, next = null;
|
|
for (const p of programs) {
|
|
if (p.start <= at && at < p.stop) now = p;
|
|
else if (p.start > at && !next) next = p;
|
|
}
|
|
return { now, next };
|
|
}
|
|
}
|