// 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-/ 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>/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 ) 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, '"'); const rows = [...channels.values()].sort((a, b) => a.id.localeCompare(b.id)); const xml = ['', '']; for (const r of rows) xml.push(` ${esc(r.name)}`); xml.push(''); 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`);