#!/usr/bin/env python3 import os import re import html import requests import xml.etree.ElementTree as ET from datetime import datetime, timezone from zoneinfo import ZoneInfo # ---------------- CONFIG ---------------- EPG_URL = "https://insight-api-shared.univtec.com/interface/epg" # Stable TVJ object ID returned by the new Univtec API CHANNEL_ID = "66e312890478fd00235244cb" TVG_ID = "TVJ.jm@SD" CHANNEL_NAME = "Television Jamaica" CHANNEL_ICON = "https://www.televisionjamaica.com/Portals/0/tvj_logo.png" # Podman/container output path OUTPUT_FILE = os.environ.get( "OUTPUT_FILE", "/app/output/tvj.xml", ) REQUEST_HEADERS = { "accept": "application/json", "platform": "web", "x-tenant-id": "rjr", } # ---------------------------------------- # Resolve timezone for LOGGING ONLY TZ_NAME = os.environ.get("TZ", "UTC") try: LOG_TZ = ZoneInfo(TZ_NAME) except Exception: print(f"Invalid TZ '{TZ_NAME}', falling back to UTC") LOG_TZ = timezone.utc def fmt_log_time(dt_utc: datetime) -> str: """Format a UTC datetime into the configured log timezone.""" return dt_utc.astimezone(LOG_TZ).strftime("%Y-%m-%d %H:%M:%S %Z") 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"", "\n", value, flags=re.IGNORECASE) value = re.sub(r"", "\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 YYYY 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): 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_tvj_events(): """ Fetch the complete RJR EPG response and extract the TVJ object. """ resp = requests.get( EPG_URL, headers=REQUEST_HEADERS, timeout=30, ) resp.raise_for_status() data = resp.json() if not isinstance(data, list) or not data: raise RuntimeError( "EPG response is empty or invalid " "(expected a non-empty list)." ) channel = next( ( item for item in data if item.get("id") == CHANNEL_ID ), None, ) if channel is None: raise RuntimeError( f"TVJ channel ID {CHANNEL_ID} " "was not found in EPG response." ) epg_object = channel.get("epgObject") if not isinstance(epg_object, dict): raise RuntimeError( "TVJ EPG response has no valid epgObject." ) events = epg_object.get("events") if not isinstance(events, list): raise RuntimeError( "TVJ epgObject.events is missing or invalid." ) return events def main(): run_time_utc = datetime.now(timezone.utc) print( f"[{fmt_log_time(run_time_utc)}] " f"Script run started (TZ={TZ_NAME})" ) events = fetch_tvj_events() # Discard malformed entries and provider filler. valid_events = [] for item in events: if not isinstance(item, dict): continue if not item.get("start") or not item.get("end"): continue title = clean_text(item.get("title")) if title and title.casefold() == "no program": continue try: start_dt = parse_iso8601(item["start"]) end_dt = parse_iso8601(item["end"]) except (ValueError, TypeError): continue if end_dt <= start_dt: continue valid_events.append((item, start_dt, end_dt)) if not valid_events: raise RuntimeError( "No valid TVJ programmes were returned." ) # Sort defensively in case provider changes ordering. valid_events.sort(key=lambda x: x[1]) last_update_utc = max( end_dt for _, _, end_dt in valid_events ) tv = ET.Element( "tv", attrib={ "generator-info-name": "Univtec / 1SpotMedia TVJ EPG (enriched)" }, ) # Channel block ch = ET.SubElement(tv, "channel", id=TVG_ID) ET.SubElement( ch, "display-name", ).text = CHANNEL_NAME ET.SubElement( ch, "icon", src=CHANNEL_ICON, ) # Programmes for item, start_dt, end_dt in valid_events: programme = ET.SubElement( tv, "programme", start=datetime_to_xmltv(start_dt), stop=datetime_to_xmltv(end_dt), channel=TVG_ID, ) title = ( clean_text(item.get("title")) or clean_text(get_custom(item).get("seriesTitle")) or "Unknown Program" ) ET.SubElement( programme, "title", lang="en", ).text = title ET.SubElement( programme, "desc", lang="en", ).text = get_description(item) add_categories(programme, item) add_episode_numbers(programme, item) add_date(programme, item) add_length(programme, item) add_rating(programme, item) add_icon(programme, item) # Ensure output directory exists. output_dir = os.path.dirname(OUTPUT_FILE) if output_dir: os.makedirs(output_dir, exist_ok=True) # Write XMLTV file. ET.ElementTree(tv).write( OUTPUT_FILE, encoding="utf-8", xml_declaration=True, ) done_utc = datetime.now(timezone.utc) print( f"[{fmt_log_time(done_utc)}] " f"XMLTV written: {OUTPUT_FILE}" ) print( f"Summary: Programmes={len(valid_events)}, " f"Last run={fmt_log_time(run_time_utc)}, " f"EPG updated until={fmt_log_time(last_update_utc)}" ) if __name__ == "__main__": main()