Refactor EPG into shared engine with configurable TVJ channels
Build and Publish TVJ EPG Image / build (push) Successful in 29s
Build and Publish TVJ EPG Image / build (push) Successful in 29s
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Configurable XMLTV and M3U engine."""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Built-in channels and environment configuration."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from epg.models import Channel
|
||||
|
||||
BUILTINS = {
|
||||
"tvj": Channel(
|
||||
id="TVJ.jm@SD", name="Television Jamaica",
|
||||
channel_id="66e312890478fd00235244cb",
|
||||
stream_url="https://vod2live.univtec.com/manifest/a99a1804-dc83-411f-8c1c-b62f08cdfa59.m3u8",
|
||||
icon="https://www.televisionjamaica.com/Portals/0/tvj_logo.png",
|
||||
),
|
||||
"tvjsn": Channel(
|
||||
id="TVJSN.jm@SD", name="TVJ Sports Network",
|
||||
channel_id="66e4618e42b97c0024de5231",
|
||||
stream_url="https://vod2live.univtec.com/manifest/65cf631b-d422-49e0-ab25-07d5aa08ca8f/7660000.m3u8",
|
||||
icon="https://rjr.vod.immergo.tv/rjr/uploaded/85c62d19-8e57-49b5-820a-acca5adcc1c9.png",
|
||||
group="Sports",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def load(environ=None):
|
||||
env = os.environ if environ is None else environ
|
||||
keys = list(dict.fromkeys(k.strip().lower() for k in env.get("CHANNELS", "tvj").split(",") if k.strip()))
|
||||
if not keys:
|
||||
raise ValueError("CHANNELS must select at least one channel")
|
||||
unknown = set(keys) - BUILTINS.keys()
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown CHANNELS: {', '.join(sorted(unknown))}; available: {', '.join(BUILTINS)}")
|
||||
xml_path = Path(env.get("OUTPUT_FILE", "/app/output/tvj.xml"))
|
||||
m3u_path = Path(env.get("M3U_OUTPUT_FILE", str(xml_path.with_name("tvj.m3u"))))
|
||||
if xml_path.resolve() == m3u_path.resolve():
|
||||
raise ValueError("OUTPUT_FILE and M3U_OUTPUT_FILE must be different")
|
||||
return [BUILTINS[k] for k in keys], xml_path, m3u_path
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Fetch each provider feed once and publish selected channels."""
|
||||
from datetime import datetime
|
||||
import sys
|
||||
from epg.config import load
|
||||
from epg.providers import PROVIDERS
|
||||
from epg.writers import atomic_write, m3u, xmltv
|
||||
|
||||
|
||||
def run(environ=None):
|
||||
channels, xml_path, m3u_path = load(environ)
|
||||
feeds = {}
|
||||
schedules = {}
|
||||
for channel in channels:
|
||||
provider = PROVIDERS[channel.provider]
|
||||
key = (channel.provider, channel.feed_url, channel.tenant)
|
||||
if key not in feeds:
|
||||
feeds[key] = provider.fetch(channel.feed_url, channel.tenant)
|
||||
schedules[channel.id] = provider.programmes(feeds[key], channel.channel_id)
|
||||
# Fetch and render everything before replacing any previous output.
|
||||
xml_content = xmltv(channels, schedules)
|
||||
playlist = m3u(channels)
|
||||
atomic_write(xml_path, xml_content)
|
||||
atomic_write(m3u_path, playlist)
|
||||
print(f"[{datetime.now().astimezone().isoformat()}] Wrote {xml_path} and {m3u_path}", flush=True)
|
||||
for channel in channels:
|
||||
programmes = schedules[channel.id]
|
||||
print(f"{channel.name}: {len(programmes)} programmes, through {max(p.stop for p in programmes).isoformat()}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
run()
|
||||
except Exception as error:
|
||||
print(f"EPG update failed: {error}", file=sys.stderr, flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Common channel and programme definitions shared by providers and writers."""
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Channel:
|
||||
id: str
|
||||
name: str
|
||||
stream_url: str
|
||||
provider: str = "univtec"
|
||||
channel_id: str = ""
|
||||
feed_url: str = "https://insight-api-shared.univtec.com/interface/epg"
|
||||
tenant: str = "rjr"
|
||||
icon: str = ""
|
||||
group: str = "General"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Programme:
|
||||
start: datetime
|
||||
stop: datetime
|
||||
title: str
|
||||
description: str
|
||||
# Optional XMLTV elements normalized by the provider (category, rating, etc.).
|
||||
metadata: tuple[Element, ...] = ()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Provider plugins implement fetch(url, tenant) and programmes(data, channel_id)."""
|
||||
from epg.providers import univtec
|
||||
|
||||
PROVIDERS = {"univtec": univtec}
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Univtec feed adapter and metadata normalization."""
|
||||
import html
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
import xml.etree.ElementTree as ET
|
||||
import requests
|
||||
from epg.models import Programme
|
||||
|
||||
def parse_iso8601(value: str) -> datetime:
|
||||
"""Parse an ISO-8601 timestamp returned by Univtec."""
|
||||
if not value:
|
||||
raise ValueError("Empty timestamp")
|
||||
|
||||
# Python accepts +00:00 more consistently than trailing Z.
|
||||
if value.endswith("Z"):
|
||||
value = value[:-1] + "+00:00"
|
||||
|
||||
dt = datetime.fromisoformat(value)
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def datetime_to_xmltv(dt: datetime) -> str:
|
||||
"""Convert datetime to XMLTV UTC timestamp."""
|
||||
return dt.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S +0000")
|
||||
|
||||
|
||||
def clean_text(value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
value = str(value).strip()
|
||||
return value if value else None
|
||||
|
||||
|
||||
def strip_html(value):
|
||||
"""Convert provider HTML descriptions to normal text."""
|
||||
value = clean_text(value)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
value = re.sub(r"<br\s*/?>", "\n", value, flags=re.IGNORECASE)
|
||||
value = re.sub(r"</p\s*>", "\n", value, flags=re.IGNORECASE)
|
||||
value = re.sub(r"<[^>]+>", "", value)
|
||||
value = html.unescape(value)
|
||||
|
||||
lines = [line.strip() for line in value.splitlines()]
|
||||
value = " ".join(line for line in lines if line)
|
||||
|
||||
return clean_text(value)
|
||||
|
||||
|
||||
def get_custom(item: dict) -> dict:
|
||||
custom = item.get("custom")
|
||||
return custom if isinstance(custom, dict) else {}
|
||||
|
||||
|
||||
def get_description(item: dict) -> str:
|
||||
"""
|
||||
Prefer the normal description.
|
||||
Fall back to custom.longDescription with HTML removed.
|
||||
"""
|
||||
custom = get_custom(item)
|
||||
|
||||
desc = (
|
||||
clean_text(item.get("description"))
|
||||
or strip_html(custom.get("longDescription"))
|
||||
)
|
||||
|
||||
return desc or "No description available."
|
||||
|
||||
|
||||
def add_categories(programme_el, item: dict):
|
||||
"""Add each provider genre as an XMLTV category."""
|
||||
custom = get_custom(item)
|
||||
genres = custom.get("genre")
|
||||
|
||||
if isinstance(genres, str):
|
||||
genres = [genres]
|
||||
|
||||
if not isinstance(genres, list):
|
||||
return
|
||||
|
||||
for genre in genres:
|
||||
genre = clean_text(genre)
|
||||
if genre:
|
||||
ET.SubElement(
|
||||
programme_el,
|
||||
"category",
|
||||
lang="en"
|
||||
).text = genre
|
||||
|
||||
|
||||
def add_episode_numbers(programme_el, item: dict):
|
||||
"""
|
||||
Add episode number using xmltv_ns when available.
|
||||
|
||||
xmltv_ns is zero-based.
|
||||
"""
|
||||
custom = get_custom(item)
|
||||
|
||||
season = custom.get("seasonNumber")
|
||||
episode = custom.get("episodeNumber")
|
||||
|
||||
try:
|
||||
season_i = int(season) if season is not None else None
|
||||
except (ValueError, TypeError):
|
||||
season_i = None
|
||||
|
||||
try:
|
||||
episode_i = int(episode) if episode is not None else None
|
||||
except (ValueError, TypeError):
|
||||
episode_i = None
|
||||
|
||||
if season_i is None and episode_i is None:
|
||||
return
|
||||
|
||||
s0 = (
|
||||
season_i - 1
|
||||
if season_i is not None and season_i >= 1
|
||||
else 0
|
||||
)
|
||||
|
||||
e0 = (
|
||||
episode_i - 1
|
||||
if episode_i is not None and episode_i >= 1
|
||||
else 0
|
||||
)
|
||||
|
||||
ep = ET.SubElement(
|
||||
programme_el,
|
||||
"episode-num",
|
||||
system="xmltv_ns"
|
||||
)
|
||||
|
||||
if season_i is not None and episode_i is not None:
|
||||
ep.text = f"{s0}.{e0}."
|
||||
elif season_i is not None:
|
||||
ep.text = f"{s0}.."
|
||||
else:
|
||||
ep.text = f".{e0}."
|
||||
|
||||
|
||||
def add_date(programme_el, item: dict):
|
||||
"""
|
||||
Add <date>YYYY</date> using custom.airDate when available.
|
||||
"""
|
||||
custom = get_custom(item)
|
||||
air_date = custom.get("airDate")
|
||||
|
||||
if air_date is None:
|
||||
return
|
||||
|
||||
try:
|
||||
dt = datetime.fromtimestamp(
|
||||
int(air_date) / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
|
||||
ET.SubElement(programme_el, "date").text = str(dt.year)
|
||||
|
||||
except (ValueError, TypeError, OSError, OverflowError):
|
||||
pass
|
||||
|
||||
|
||||
def add_length(programme_el, item: dict):
|
||||
"""
|
||||
Add programme duration from custom.duration.
|
||||
|
||||
Univtec duration appears to be seconds.
|
||||
"""
|
||||
custom = get_custom(item)
|
||||
duration = custom.get("duration")
|
||||
|
||||
if duration is None:
|
||||
return
|
||||
|
||||
try:
|
||||
seconds = int(duration)
|
||||
|
||||
if seconds > 0:
|
||||
ET.SubElement(
|
||||
programme_el,
|
||||
"length",
|
||||
units="seconds"
|
||||
).text = str(seconds)
|
||||
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def add_rating(programme_el, item: dict):
|
||||
"""Add provider content rating."""
|
||||
rating = clean_text(get_custom(item).get("rating"))
|
||||
|
||||
if not rating:
|
||||
return
|
||||
|
||||
rating_el = ET.SubElement(programme_el, "rating")
|
||||
ET.SubElement(rating_el, "value").text = rating
|
||||
|
||||
|
||||
def add_icon(programme_el, item: dict):
|
||||
"""Add programme artwork if supplied by Univtec."""
|
||||
custom = get_custom(item)
|
||||
|
||||
image = custom.get("image")
|
||||
poster = custom.get("poster")
|
||||
|
||||
image_url = None
|
||||
|
||||
if isinstance(image, dict):
|
||||
image_url = clean_text(image.get("downloadUrl"))
|
||||
|
||||
if not image_url and isinstance(poster, dict):
|
||||
image_url = clean_text(poster.get("downloadUrl"))
|
||||
|
||||
if image_url:
|
||||
ET.SubElement(
|
||||
programme_el,
|
||||
"icon",
|
||||
src=image_url,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def fetch(url, tenant):
|
||||
response = requests.get(url, headers={
|
||||
"accept": "application/json", "platform": "web", "x-tenant-id": tenant,
|
||||
}, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, list) or not data:
|
||||
raise ValueError("Expected a non-empty Univtec channel list")
|
||||
return data
|
||||
|
||||
|
||||
def programmes(data, channel_id):
|
||||
channel = next((c for c in data if isinstance(c, dict) and c.get("id") == channel_id), None)
|
||||
if channel is None:
|
||||
raise ValueError(f"Channel {channel_id} missing from feed")
|
||||
epg = channel.get("epgObject")
|
||||
events = epg.get("events") if isinstance(epg, dict) else None
|
||||
if not isinstance(events, list):
|
||||
raise ValueError(f"Channel {channel_id} has no valid events list")
|
||||
result = []
|
||||
for item in events:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = clean_text(item.get("title"))
|
||||
if title and title.casefold() == "no program":
|
||||
continue
|
||||
try:
|
||||
start = parse_iso8601(item.get("start"))
|
||||
stop = parse_iso8601(item.get("end"))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
if stop <= start:
|
||||
continue
|
||||
# Normalize existing enriched metadata into provider-independent XMLTV fields.
|
||||
metadata = ET.Element("metadata")
|
||||
for enrich in (add_categories, add_episode_numbers, add_date,
|
||||
add_length, add_rating, add_icon):
|
||||
enrich(metadata, item)
|
||||
result.append(Programme(
|
||||
start, stop,
|
||||
title or clean_text(get_custom(item).get("seriesTitle")) or "Unknown Program",
|
||||
get_description(item), tuple(metadata),
|
||||
))
|
||||
if not result:
|
||||
raise ValueError(f"Channel {channel_id} has no valid programmes")
|
||||
return sorted(result, key=lambda p: p.start)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Provider-independent output generation."""
|
||||
import copy
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timezone
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def xmltv(channels, schedules):
|
||||
root = ET.Element("tv", {"generator-info-name": "TVJ EPG Engine"})
|
||||
for channel in channels:
|
||||
node = ET.SubElement(root, "channel", id=channel.id)
|
||||
ET.SubElement(node, "display-name").text = channel.name
|
||||
if channel.icon:
|
||||
ET.SubElement(node, "icon", src=channel.icon)
|
||||
for channel in channels:
|
||||
for programme in schedules[channel.id]:
|
||||
node = ET.SubElement(root, "programme", channel=channel.id,
|
||||
start=programme.start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S +0000"),
|
||||
stop=programme.stop.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S +0000"))
|
||||
ET.SubElement(node, "title", lang="en").text = programme.title
|
||||
ET.SubElement(node, "desc", lang="en").text = programme.description
|
||||
node.extend(copy.deepcopy(programme.metadata))
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def m3u(channels):
|
||||
lines = ["#EXTM3U"]
|
||||
for channel in channels:
|
||||
lines.extend([
|
||||
f'#EXTINF:-1 tvg-id="{channel.id}" tvg-logo="{channel.icon}" group-title="{channel.group}",{channel.name}',
|
||||
channel.stream_url,
|
||||
])
|
||||
return ("\n".join(lines) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def atomic_write(path, content):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as output:
|
||||
temporary = output.name
|
||||
output.write(content)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.chmod(temporary, 0o644)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary and os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
Reference in New Issue
Block a user