Files
pondering-orb/web/providers/source-provider.js
T
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

187 lines
6.3 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 Playable HLS URLs (relay-wrapped where needed), primary + fallbacks.
* @property {string[]} [probeUrls] Raw upstream https URLs for the health prober (pre-relay).
* @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 };
}
}