Skip to content

Commit 207541e

Browse files
Add files via upload
1 parent b1015d0 commit 207541e

3 files changed

Lines changed: 182 additions & 7 deletions

File tree

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@ Generador automatizado de listas M3U, JSON y XMLTV para canales FAST/IPTV.
88
- `output/all.json.gz`: catálogo completo en JSON comprimido.
99
- `output/[platform]_all.m3u`: lista por plataforma.
1010
- `output/[platform]_[country].m3u`: lista por plataforma y país, cuando se puede detectar país.
11-
- `output/xmltv.xml.gz`: XMLTV comprimido desde EPGShare.
11+
- `output/all.xml.gz`: XMLTV filtrado por nombre de canal y comprimido al máximo desde EPGShare.
1212
- `output/summary.json`: resumen de ejecución.
1313
- `output/manifest.json`: índice de archivos generados.
1414

1515
## Automatización
1616

17-
El workflow de GitHub Actions se ejecuta cada 6 horas y también manualmente:
17+
El workflow de GitHub Actions se ejecuta cada 6 horas y también manualmente. En cada ejecución hace dos cosas: sube `output/` como artifact y hace commit de `output/` al repositorio:
1818

1919
```yaml
2020
cron: "0 */6 * * *"
2121
```
2222
23-
Publica los resultados como artefacto de Actions y, opcionalmente, puede hacer commit al propio repo si activas `COMMIT_OUTPUTS=true`.
23+
Publica los resultados como artifact de GitHub Actions y también guarda/actualiza `output/` en el propio repositorio mediante commit automático.
2424

2525
## Uso local
2626

@@ -49,6 +49,20 @@ El generador intenta resolver, mediante `HEAD` o `GET` sin descargar el vídeo c
4949

5050
La URL original se conserva en JSON como `original_url` y la resuelta como `url`.
5151

52+
53+
## XMLTV filtrado y límite de 100 MB
54+
55+
El XMLTV de origen puede superar fácilmente los 200 MB comprimido. IPTVFast no lo copia entero: lo filtra para conservar solo canales cuyo `id` o `display-name` coincida de forma flexible con los nombres, `tvg-id` o `tvg-name` de los canales generados.
56+
57+
Después lo guarda como `output/all.xml.gz` con `gzip` nivel 9 y `mtime=0`. Por defecto intenta mantenerlo por debajo de 100 MB (`IPTVFAST_MAX_XMLTV_GZ_BYTES`). Si sigue pesando demasiado, reduce automáticamente la ventana de guía desde 7 días hasta 1 día.
58+
59+
Variables útiles:
60+
61+
```bash
62+
IPTVFAST_MAX_XMLTV_GZ_BYTES=104857600
63+
IPTVFAST_XMLTV_GZIP_LEVEL=9
64+
```
65+
5266
## Aviso
5367

5468
Muchas fuentes pueden estar geobloqueadas, caídas o cambiar sin previo aviso. Este repositorio solo agrega URLs públicas indicadas en la configuración.
7.21 KB
Binary file not shown.

iptvfast/generate.py

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import os
88
import re
99
import sys
10+
import tempfile
11+
import xml.etree.ElementTree as ET
1012
from dataclasses import dataclass, asdict, field
1113
from datetime import datetime, timezone
1214
from pathlib import Path
@@ -29,6 +31,8 @@
2931
TIMEOUT = int(os.getenv("IPTVFAST_TIMEOUT", "25"))
3032
RESOLVE_REDIRECTS = os.getenv("IPTVFAST_RESOLVE_REDIRECTS", "true").lower() == "true"
3133
WRITE_JSON_PLAIN = os.getenv("IPTVFAST_WRITE_JSON_PLAIN", "false").lower() == "true"
34+
MAX_XMLTV_GZ_BYTES = int(os.getenv("IPTVFAST_MAX_XMLTV_GZ_BYTES", str(100 * 1024 * 1024)))
35+
XMLTV_GZIP_LEVEL = int(os.getenv("IPTVFAST_XMLTV_GZIP_LEVEL", "9"))
3236

3337

3438
JMP_RE = re.compile(
@@ -283,6 +287,153 @@ def dedupe(channels: list[Channel]) -> list[Channel]:
283287
return out
284288

285289

290+
def norm_match_text(value: str) -> str:
291+
value = (value or "").casefold()
292+
value = re.sub(r"&", " and ", value)
293+
value = re.sub(r"[^a-z0-9áéíóúüñçàèìòùäëïöüâêîôû]+", "", value, flags=re.I)
294+
return value
295+
296+
297+
def xmltv_time_to_dt(value: str):
298+
if not value:
299+
return None
300+
m = re.match(r"(\d{14})(?:\s*([+-]\d{4}))?", value)
301+
if not m:
302+
return None
303+
raw, tz = m.groups()
304+
try:
305+
if tz:
306+
return datetime.strptime(raw + tz, "%Y%m%d%H%M%S%z")
307+
return datetime.strptime(raw, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
308+
except Exception:
309+
return None
310+
311+
312+
def channel_match_tokens(channels: list[Channel]) -> set[str]:
313+
tokens: set[str] = set()
314+
for ch in channels:
315+
for value in (ch.tvg_id, ch.tvg_name, ch.name):
316+
n = norm_match_text(value)
317+
if len(n) >= 3:
318+
tokens.add(n)
319+
return tokens
320+
321+
322+
def channel_matches_xmltv(channel_id: str, display_names: list[str], tokens: set[str]) -> bool:
323+
candidates = [channel_id, *display_names]
324+
normalized = [norm_match_text(x) for x in candidates if x]
325+
for n in normalized:
326+
if not n:
327+
continue
328+
if n in tokens:
329+
return True
330+
# Coincidencia flexible por nombre: sirve para pequeñas diferencias de mayúsculas,
331+
# espacios, guiones, acentos o sufijos.
332+
for t in tokens:
333+
if len(t) >= 5 and (t in n or n in t):
334+
return True
335+
return False
336+
337+
338+
def clone_element(elem: ET.Element) -> ET.Element:
339+
return ET.fromstring(ET.tostring(elem, encoding="utf-8"))
340+
341+
342+
def write_filtered_xmltv_gz(
343+
source_bytes: bytes,
344+
out_path: Path,
345+
channels: list[Channel],
346+
max_gz_bytes: int = MAX_XMLTV_GZ_BYTES,
347+
max_days: int = 7,
348+
) -> dict[str, object]:
349+
"""Filter XMLTV to generated channel names and gzip with max compression.
350+
351+
Strategy:
352+
1. Match XMLTV <channel> by id/display-name against generated M3U channel names/tvg ids.
353+
2. Keep only <programme> whose channel survived.
354+
3. Start with up to 7 days, then reduce days if gzip is still over max_gz_bytes.
355+
"""
356+
if source_bytes[:2] == b"\x1f\x8b":
357+
xml_bytes = gzip.decompress(source_bytes)
358+
else:
359+
xml_bytes = source_bytes
360+
361+
tokens = channel_match_tokens(channels)
362+
best_meta: dict[str, object] = {}
363+
best_payload = b""
364+
365+
now = datetime.now(timezone.utc)
366+
367+
# Try requested days first, then reduce to satisfy <=100 MB.
368+
for days in range(int(max_days), 0, -1):
369+
cutoff = now.timestamp() + days * 86400
370+
371+
root_out = ET.Element("tv", {
372+
"generator-info-name": "IPTVFast filtered XMLTV",
373+
"source-info-name": "epgshare01 filtered by generated channel names",
374+
})
375+
376+
kept_ids: set[str] = set()
377+
kept_channels = 0
378+
kept_programmes = 0
379+
380+
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp:
381+
tmp.write(xml_bytes)
382+
tmp_path = tmp.name
383+
384+
try:
385+
# Pass 1: channels
386+
for event, elem in ET.iterparse(tmp_path, events=("end",)):
387+
if elem.tag == "channel":
388+
cid = elem.attrib.get("id", "")
389+
names = [dn.text or "" for dn in elem.findall("display-name")]
390+
if channel_matches_xmltv(cid, names, tokens):
391+
kept_ids.add(cid)
392+
root_out.append(clone_element(elem))
393+
kept_channels += 1
394+
elem.clear()
395+
396+
# Pass 2: programmes
397+
for event, elem in ET.iterparse(tmp_path, events=("end",)):
398+
if elem.tag == "programme":
399+
cid = elem.attrib.get("channel", "")
400+
if cid in kept_ids:
401+
start = xmltv_time_to_dt(elem.attrib.get("start", ""))
402+
if start is None or start.timestamp() <= cutoff:
403+
root_out.append(clone_element(elem))
404+
kept_programmes += 1
405+
elem.clear()
406+
finally:
407+
try:
408+
os.unlink(tmp_path)
409+
except OSError:
410+
pass
411+
412+
xml_out = ET.tostring(root_out, encoding="utf-8", xml_declaration=True)
413+
gz_payload = gzip.compress(xml_out, compresslevel=XMLTV_GZIP_LEVEL, mtime=0)
414+
415+
best_meta = {
416+
"xmltv_file": out_path.name,
417+
"xmltv_max_days_requested": max_days,
418+
"xmltv_days_written": days,
419+
"xmltv_channels_written": kept_channels,
420+
"xmltv_programmes_written": kept_programmes,
421+
"xmltv_gz_bytes": len(gz_payload),
422+
"xmltv_gz_limit_bytes": max_gz_bytes,
423+
"xmltv_gzip_level": XMLTV_GZIP_LEVEL,
424+
"xmltv_filtered_by_channel_name": True,
425+
}
426+
best_payload = gz_payload
427+
428+
if len(gz_payload) <= max_gz_bytes:
429+
break
430+
431+
out_path.write_bytes(best_payload)
432+
best_meta["xmltv_under_limit"] = len(best_payload) <= max_gz_bytes
433+
return best_meta
434+
435+
436+
286437
async def main() -> int:
287438
OUT.mkdir(exist_ok=True)
288439
cfg = yaml.safe_load(CONFIG.read_text(encoding="utf-8"))
@@ -339,16 +490,24 @@ async def process_matt(src: dict[str, str]):
339490
channels = dedupe(results)
340491
channels.sort(key=lambda c: (c.platform, c.country, c.name.lower()))
341492

342-
# EPG XMLTV: keep compressed only
493+
# EPG XMLTV: filter by generated channel names and gzip with max compression.
494+
# Output name requested: all.xml.gz, max 100 MB by default.
343495
epg_url = cfg.get("epg", {}).get("url")
496+
xmltv_meta: dict[str, object] = {}
497+
local_epg_ref = "all.xml.gz"
344498
if epg_url:
345499
try:
346500
epg_bytes = await fetch_bytes(session, epg_url)
347-
(OUT / "xmltv.xml.gz").write_bytes(epg_bytes)
501+
xmltv_meta = write_filtered_xmltv_gz(
502+
epg_bytes,
503+
OUT / local_epg_ref,
504+
channels,
505+
max_gz_bytes=MAX_XMLTV_GZ_BYTES,
506+
max_days=int(cfg.get("epg", {}).get("days", 7)),
507+
)
348508
except Exception as e:
349509
errors.append({"url": epg_url, "platform": "xmltv", "error": repr(e)})
350510

351-
local_epg_ref = "xmltv.xml.gz"
352511
write_m3u(OUT / "all.m3u", channels, local_epg_ref)
353512

354513
# Platform and country outputs
@@ -372,8 +531,9 @@ async def process_matt(src: dict[str, str]):
372531
"channels": [asdict(c) for c in channels],
373532
"epg": {
374533
"source": epg_url,
375-
"local": "xmltv.xml.gz",
534+
"local": local_epg_ref,
376535
"days": cfg.get("epg", {}).get("days", 7),
536+
**xmltv_meta,
377537
},
378538
}
379539
write_json_gz(OUT / "all.json.gz", payload)
@@ -390,6 +550,7 @@ async def process_matt(src: dict[str, str]):
390550
"generated_at": payload["generated_at"],
391551
"channel_count": len(channels),
392552
"platform_count": len(by_platform),
553+
"xmltv": xmltv_meta,
393554
"errors": errors[:200],
394555
}
395556
(OUT / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")

0 commit comments

Comments
 (0)