| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114 |
- // EPG binder. Run AFTER build-iptv.mjs (refresh.ts does both + grab). It:
- // 1. matches each playlist channel to an iptv-org/epg source:
- // - real tvg-id -> epg channel with the same BASE id (ignoring @feed suffix)
- // - Pluto channels -> /plu-<id>/ in the URL IS epg's pluto.tv site_id, so we
- // synthesise a tvg-id even when upstream left it blank
- // 2. rewrites the playlist's tvg-id to a canonical id + adds the url-tvg header
- // 3. writes /tmp/iptv-org-build/custom.channels.xml for `grab`
- // Samsung & Tubi have no epg site upstream, so they only bind if a real tvg-id matches.
- import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
- import { fileURLToPath } from 'node:url';
- import path from 'node:path';
- const HERE = path.dirname(fileURLToPath(import.meta.url));
- const M3U = path.join(HERE, 'iptv.m3u');
- const SITES = '/tmp/iptv-org-build/epg/sites';
- const OUTXML = '/tmp/iptv-org-build/custom.channels.xml';
- // Public URL where guide.xml is served — must match deploy.ts's bucket path.
- const EPG_URL = 'https://chillidonut.com/junk/iptv/guide.xml';
- if (!existsSync(SITES)) {
- process.stderr.write(`epg clone not found at ${SITES}\n` +
- 'Run: git clone --depth 1 https://github.com/iptv-org/epg.git /tmp/iptv-org-build/epg (or use refresh.ts)\n');
- process.exit(1);
- }
- const baseId = (x) => (x || '').split('@')[0].trim();
- const cleanName = (n) => n.replace(/\s*\(\d+p\)|\s*\[[^\]]*\]|\s*\([^)]*\)\s*$/g, '').trim() || n;
- // ---------- index epg channel mappings ----------
- const chanRe = /<channel\s+([^>]*?)>([^<]*)<\/channel>/g;
- const attrRe = /([\w]+)="([^"]*)"/g;
- const baseIdx = new Map(); // base(xmltv_id) -> {site, site_id, lang}
- const plutoIdx = new Map(); // pluto site_id (hex) -> {xmltv_id, lang}
- for (const site of readdirSync(SITES)) {
- let dir;
- try { dir = readdirSync(path.join(SITES, site)); } catch { continue; }
- for (const f of dir) {
- if (!f.endsWith('.channels.xml')) continue;
- for (const c of readFileSync(path.join(SITES, site, f), 'utf8').matchAll(chanRe)) {
- const a = {};
- for (const m of c[1].matchAll(attrRe)) a[m[1]] = m[2];
- if (site === 'pluto.tv' && a.site_id) plutoIdx.set(a.site_id, { xmltv_id: a.xmltv_id, lang: a.lang || 'en' });
- const b = baseId(a.xmltv_id);
- if (b && a.site_id && !baseIdx.has(b)) baseIdx.set(b, { site: a.site, site_id: a.site_id, lang: a.lang || 'en' });
- }
- }
- }
- // ---------- resolve each playlist channel ----------
- function resolve(url, tvgid, name) {
- const pm = url.match(/plu-([0-9a-f]{16,})/i);
- if (pm && plutoIdx.has(pm[1])) {
- const e = plutoIdx.get(pm[1]);
- return { id: baseId(e.xmltv_id) || `pluto-${pm[1]}`, site: 'pluto.tv', site_id: pm[1], lang: e.lang, name: cleanName(name) };
- }
- const b = baseId(tvgid);
- if (b && baseIdx.has(b)) {
- const e = baseIdx.get(b);
- return { id: b, site: e.site, site_id: e.site_id, lang: e.lang, name: cleanName(name) };
- }
- return { id: b }; // keep base id if any (no grab source), else ''
- }
- // ---------- rewrite playlist + collect channels ----------
- const lines = readFileSync(M3U, 'utf8').split('\n');
- const channels = new Map(); // site|site_id -> row
- const seenIds = new Set(); // one grab source (and one guide <channel>) per id
- let plutoMatched = 0, idMatched = 0;
- const bySection = {};
- let section = '';
- for (let i = 0; i < lines.length; i++) {
- const l = lines[i];
- if (l.startsWith('# SECTION:')) section = l.replace(/.*SECTION:\s*/, '').replace(/\s{2,}.*/, '');
- if (l === '#EXTM3U') { lines[i] = `#EXTM3U url-tvg="${EPG_URL}"`; continue; }
- if (!l.startsWith('#EXTINF')) continue;
- let url = '';
- for (let j = i + 1; j < lines.length; j++) {
- const t = lines[j].trim();
- if (!t || t.startsWith('#')) { if (t.startsWith('#EXTINF')) break; continue; }
- url = t; break;
- }
- if (!url) continue;
- const tvgid = (l.match(/tvg-id="([^"]*)"/) || [])[1] || '';
- const name = l.slice(l.indexOf(',') + 1);
- const r = resolve(url, tvgid, name);
- lines[i] = l.replace(/tvg-id="[^"]*"/, `tvg-id="${r.id || ''}"`);
- bySection[section] = bySection[section] || { have: 0, total: 0 };
- bySection[section].total++;
- if (r.site && r.site_id) {
- bySection[section].have++;
- if (!seenIds.has(r.id)) {
- seenIds.add(r.id);
- if (r.site === 'pluto.tv') plutoMatched++; else idMatched++;
- channels.set(`${r.site}|${r.site_id}`, r);
- }
- }
- }
- writeFileSync(M3U, lines.join('\n'));
- // ---------- write channels.xml ----------
- const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
- const rows = [...channels.values()].sort((a, b) => a.id.localeCompare(b.id));
- const xml = ['<?xml version="1.0" encoding="UTF-8"?>', '<channels>'];
- for (const r of rows)
- xml.push(` <channel site="${esc(r.site)}" lang="${esc(r.lang)}" xmltv_id="${esc(r.id)}" site_id="${esc(r.site_id)}">${esc(r.name)}</channel>`);
- xml.push('</channels>');
- writeFileSync(OUTXML, xml.join('\n') + '\n');
- // ---------- report ----------
- process.stderr.write(`EPG url-tvg: ${EPG_URL}\n`);
- process.stderr.write(`grab channels: ${rows.length} (pluto: ${plutoMatched}, by-id: ${idMatched}) -> ${OUTXML}\n\n`);
- process.stderr.write('EPG-bound channels per section:\n');
- for (const [s, v] of Object.entries(bySection).sort())
- process.stderr.write(` ${String(v.have).padStart(4)} / ${String(v.total).padEnd(4)} ${s}\n`);
|