a1e3e6852f
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>
176 lines
6.7 KiB
JavaScript
176 lines
6.7 KiB
JavaScript
// 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,
|
|
probeUrls: urls, // raw https upstreams for the /probe endpoint
|
|
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. Returns cached programs (populated by loadEpgXml via the off-thread
|
|
* worker). Empty until an XMLTV feed is loaded — the guide still renders
|
|
* channel rows; now/next is simply blank.
|
|
*/
|
|
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 an XMLTV feed OFF the main thread and populate the EPG cache.
|
|
* @param {string} xmlString
|
|
* @returns {Promise<number>} number of programmes indexed
|
|
*/
|
|
async loadEpgXml(xmlString) {
|
|
const programs = await this._parseInWorker(xmlString);
|
|
const idMap = new Map((this._channels || []).map((c) => [c.guideId, c.id]));
|
|
let n = 0;
|
|
for (const p of programs) {
|
|
const cid = idMap.get(p.channel);
|
|
if (!cid) continue;
|
|
const arr = this._epgCache.get(cid) || this._epgCache.set(cid, []).get(cid);
|
|
arr.push({ channelId: cid, title: clip(p.title), desc: clip(p.desc), start: p.start, stop: p.stop });
|
|
n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
_parseInWorker(xmlString) {
|
|
return new Promise((resolve, reject) => {
|
|
let worker;
|
|
try {
|
|
worker = new Worker(new URL('../epg-worker.js', import.meta.url), { type: 'module' });
|
|
} catch (e) {
|
|
return reject(e);
|
|
}
|
|
const id = Math.random().toString(36).slice(2);
|
|
worker.onmessage = (e) => {
|
|
if (e.data?.id !== id) return;
|
|
worker.terminate();
|
|
e.data.ok ? resolve(e.data.programs) : reject(new Error(e.data.error));
|
|
};
|
|
worker.onerror = (e) => { worker.terminate(); reject(e); };
|
|
worker.postMessage({ id, xml: xmlString });
|
|
});
|
|
}
|
|
}
|