// 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} [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} */ 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} */ 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} */ 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} */ this.providers = new Map(); /** @type {Channel[]} */ this._channels = []; /** @type {Map} */ 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} */ 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} */ 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} */ 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 }; } }