// 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; }