feat: initial release of The Pondering Orb
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>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
// iptv-org.js — IptvOrgProvider. Real public channels via the iptv-org JSON API.
|
||||
//
|
||||
// Data source of truth = the JSON API (CORS-clean GitHub Pages), NOT raw M3U:
|
||||
// join channels.json + streams.json + logos.json, EPG via guides.json (XMLTV).
|
||||
// Stream URLs are routed through the same-origin /relay so HLS.js can play them
|
||||
// without CORS and the ambient canvas stays untainted.
|
||||
//
|
||||
// SECURITY: every field from the API is treated as hostile input. We never use
|
||||
// innerHTML anywhere; here we additionally scheme-allowlist URLs and cap sizes.
|
||||
|
||||
import { SourceProvider } from './source-provider.js';
|
||||
|
||||
const API = 'https://iptv-org.github.io/api';
|
||||
const MAX_CHANNELS = 20000; // anti client-side DoS
|
||||
const MAX_FIELD = 200; // cap any displayed string
|
||||
|
||||
const clip = (s) => (typeof s === 'string' ? s.slice(0, MAX_FIELD) : '');
|
||||
|
||||
/** Only https logos (or data:). Anything else → dropped. */
|
||||
function safeLogo(url) {
|
||||
if (typeof url !== 'string') return undefined;
|
||||
if (/^https:\/\//i.test(url) || /^data:image\//i.test(url)) return url;
|
||||
return undefined;
|
||||
}
|
||||
/** Only https stream URLs survive. */
|
||||
function safeStream(url) {
|
||||
return (typeof url === 'string' && /^https:\/\//i.test(url)) ? url : null;
|
||||
}
|
||||
/** Wrap an upstream URL so it flows through the hardened same-origin relay. */
|
||||
function viaRelay(url) { return '/relay?url=' + encodeURIComponent(url); }
|
||||
|
||||
async function getJSON(url, useRelay) {
|
||||
// Under the production CSP (connect-src 'self') the JSON API must also flow
|
||||
// through the relay; on a plain static server we fetch it directly.
|
||||
const target = useRelay ? viaRelay(url) : url;
|
||||
const r = await fetch(target, { credentials: 'omit', referrerPolicy: 'no-referrer' });
|
||||
if (!r.ok) throw new Error(`${url} → ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export class IptvOrgProvider extends SourceProvider {
|
||||
constructor(opts = {}) {
|
||||
super();
|
||||
this.useRelay = opts.useRelay !== false; // default on (same-origin server)
|
||||
this._channels = null;
|
||||
this._streamsByCh = new Map(); // channelId -> https url[]
|
||||
this._guideByCh = new Map(); // channelId -> xmltv url
|
||||
this._epgCache = new Map(); // channelId -> Program[]
|
||||
}
|
||||
|
||||
get id() { return 'iptv-org'; }
|
||||
get name() { return 'iptv-org'; }
|
||||
get supportsGuides() { return true; }
|
||||
|
||||
async listChannels() {
|
||||
if (this._channels) return this._channels;
|
||||
const [channels, streams, logos, guides] = await Promise.all([
|
||||
getJSON(`${API}/channels.json`, this.useRelay),
|
||||
getJSON(`${API}/streams.json`, this.useRelay),
|
||||
getJSON(`${API}/logos.json`, this.useRelay).catch(() => []),
|
||||
getJSON(`${API}/guides.json`, this.useRelay).catch(() => []),
|
||||
]);
|
||||
|
||||
// streams: channel -> [https urls]
|
||||
for (const s of streams) {
|
||||
const cid = s.channel;
|
||||
const url = safeStream(s.url);
|
||||
if (!cid || !url) continue;
|
||||
(this._streamsByCh.get(cid) || this._streamsByCh.set(cid, []).get(cid)).push(url);
|
||||
}
|
||||
// first guide source per channel
|
||||
for (const g of guides) {
|
||||
if (g.channel && g.site && !this._guideByCh.has(g.channel)) {
|
||||
// guides.json entries point at grabber sites, not always direct XMLTV;
|
||||
// we store the channel's xmltv id for matching when an EPG feed is wired.
|
||||
this._guideByCh.set(g.channel, g);
|
||||
}
|
||||
}
|
||||
const logoByCh = new Map();
|
||||
for (const l of logos) {
|
||||
if (l.channel && !logoByCh.has(l.channel)) {
|
||||
const u = safeLogo(l.url); if (u) logoByCh.set(l.channel, u);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('./source-provider.js').Channel[]} */
|
||||
const out = [];
|
||||
for (const c of channels) {
|
||||
if (out.length >= MAX_CHANNELS) break;
|
||||
const urls = this._streamsByCh.get(c.id);
|
||||
if (!urls || !urls.length) continue; // only channels we can actually play
|
||||
const playable = this.useRelay ? urls.map(viaRelay) : urls;
|
||||
out.push({
|
||||
id: `iptv-org:${c.id}`,
|
||||
source: 'iptv-org',
|
||||
name: clip(c.name) || c.id,
|
||||
logo: logoByCh.get(c.id),
|
||||
group: clip((c.categories && c.categories[0]) || ''),
|
||||
country: clip(c.country || ''),
|
||||
language: clip((c.languages && c.languages[0]) || ''),
|
||||
kind: 'live',
|
||||
streamUrls: playable,
|
||||
guideId: c.id,
|
||||
});
|
||||
}
|
||||
this._channels = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
async getStreamHandle(channelId) {
|
||||
const ch = (this._channels || []).find((c) => c.id === channelId);
|
||||
if (!ch || !ch.streamUrls.length) return null;
|
||||
return { url: ch.streamUrls[0], protocol: 'hls' };
|
||||
}
|
||||
|
||||
async healthCheck(channelId) {
|
||||
const ch = (this._channels || []).find((c) => c.id === channelId);
|
||||
if (!ch || !ch.streamUrls.length) return { ok: false, error: 'no stream' };
|
||||
try {
|
||||
// probe through the relay (same SSRF-validated path); HEAD then bail
|
||||
const r = await fetch(ch.streamUrls[0], { method: 'GET', credentials: 'omit' });
|
||||
return { ok: r.ok, error: r.ok ? undefined : `status ${r.status}` };
|
||||
} catch (e) { return { ok: false, error: String(e) }; }
|
||||
}
|
||||
|
||||
/**
|
||||
* EPG. Parses XMLTV with DOMParser (never innerHTML) into Program[].
|
||||
* Phase-1 stub: returns cached programs if an XMLTV feed has been provided via
|
||||
* setEpgXml(); otherwise empty (guide still renders channel rows + now/next is
|
||||
* simply blank until an EPG source is wired in Phase 1.5).
|
||||
*/
|
||||
async getPrograms(channelId, window) {
|
||||
const cached = this._epgCache.get(channelId);
|
||||
if (!cached) return [];
|
||||
return cached.filter((p) => p.stop > window.from && p.start < window.to);
|
||||
}
|
||||
|
||||
/** Parse a raw XMLTV string and populate the EPG cache (safe: DOMParser). */
|
||||
setEpgXml(xmlString) {
|
||||
const doc = new DOMParser().parseFromString(xmlString, 'application/xml');
|
||||
if (doc.querySelector('parsererror')) return;
|
||||
// map xmltv channel id -> our namespaced id
|
||||
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
|
||||
for (const prog of doc.getElementsByTagName('programme')) {
|
||||
const xmlCh = prog.getAttribute('channel');
|
||||
const cid = idMap.get(xmlCh);
|
||||
if (!cid) continue;
|
||||
const start = parseXmltvTime(prog.getAttribute('start'));
|
||||
const stop = parseXmltvTime(prog.getAttribute('stop'));
|
||||
if (!start || !stop) continue;
|
||||
const titleEl = prog.getElementsByTagName('title')[0];
|
||||
const descEl = prog.getElementsByTagName('desc')[0];
|
||||
const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
|
||||
arr.push({
|
||||
channelId: cid,
|
||||
title: clip(titleEl ? titleEl.textContent : 'Program'),
|
||||
desc: clip(descEl ? descEl.textContent : ''),
|
||||
start, stop,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** XMLTV time: "20260615013000 +0000" → epoch ms. */
|
||||
function parseXmltvTime(s) {
|
||||
if (!s) return 0;
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?\s*([+-]\d{4})?/.exec(s.trim());
|
||||
if (!m) return 0;
|
||||
const [, Y, Mo, D, H, Mi, S, tz] = m;
|
||||
let ms = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +(S || 0));
|
||||
if (tz) {
|
||||
const sign = tz[0] === '-' ? -1 : 1;
|
||||
ms -= sign * ((+tz.slice(1, 3)) * 60 + (+tz.slice(3, 5))) * 60000;
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user