Update tvj_epg.py
Build and Publish TVJ EPG Image / build (push) Successful in 1m15s

This commit is contained in:
2026-09-10 01:27:04 -04:00
parent 0661a546b3
commit e6f8b0e512
+321 -93
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import re
import html
import requests import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -8,15 +10,26 @@ from zoneinfo import ZoneInfo
# ---------------- CONFIG ---------------- # ---------------- CONFIG ----------------
EPG_URL = "https://www.1spotmedia.com/index.php/api/epg/get_epg_timeline_by_id" EPG_URL = "https://insight-api-shared.univtec.com/interface/epg"
# Stable TVJ object ID returned by the new Univtec API
CHANNEL_ID = "66e312890478fd00235244cb" CHANNEL_ID = "66e312890478fd00235244cb"
TVG_ID = "TVJ.jm@SD" TVG_ID = "TVJ.jm@SD"
CHANNEL_NAME = "Television Jamaica" CHANNEL_NAME = "Television Jamaica"
CHANNEL_ICON = "https://www.televisionjamaica.com/Portals/0/tvj_logo.png" CHANNEL_ICON = "https://www.televisionjamaica.com/Portals/0/tvj_logo.png"
# Podman/container output path (mount this directory) # Podman/container output path
OUTPUT_FILE = "/app/output/tvj.xml" OUTPUT_FILE = os.environ.get(
"OUTPUT_FILE",
"/app/output/tvj.xml",
)
REQUEST_HEADERS = {
"accept": "application/json",
"platform": "web",
"x-tenant-id": "rjr",
}
# ---------------------------------------- # ----------------------------------------
@@ -34,58 +47,104 @@ def fmt_log_time(dt_utc: datetime) -> str:
return dt_utc.astimezone(LOG_TZ).strftime("%Y-%m-%d %H:%M:%S %Z") return dt_utc.astimezone(LOG_TZ).strftime("%Y-%m-%d %H:%M:%S %Z")
def epoch_ms_to_xmltv(ts_ms: int) -> str: def parse_iso8601(value: str) -> datetime:
"""Convert epoch milliseconds to XMLTV timestamp (UTC).""" """Parse an ISO-8601 timestamp returned by Univtec."""
dt = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc) if not value:
return dt.strftime("%Y%m%d%H%M%S +0000") 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 clean_text(s): def datetime_to_xmltv(dt: datetime) -> str:
if s is None: """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 return None
s = str(s).strip()
return s if s else None value = str(value).strip()
return value if value else None
def get_program_obj(item: dict) -> dict: def strip_html(value):
prog = item.get("program") """Convert provider HTML descriptions to normal text."""
return prog if isinstance(prog, dict) else {} 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: def get_description(item: dict) -> str:
""" """
Extract description from nested program object. Prefer the normal description.
Treat 'Dummy description.' as missing. Fall back to custom.longDescription with HTML removed.
""" """
prog = get_program_obj(item) custom = get_custom(item)
desc = ( desc = (
prog.get("longDescription") clean_text(item.get("description"))
or prog.get("description") or strip_html(custom.get("longDescription"))
or prog.get("shortDescription")
) )
desc = clean_text(desc)
if desc and desc.lower() == "dummy description.":
desc = None
return desc or "No description available." return desc or "No description available."
def get_category(item: dict): def add_categories(programme_el, item: dict):
"""Use displayGenre if present.""" """Add each provider genre as an XMLTV category."""
return clean_text(get_program_obj(item).get("displayGenre")) 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): def add_episode_numbers(programme_el, item: dict):
""" """
Add episode number in xmltv_ns format if possible. Add episode number using xmltv_ns when available.
xmltv_ns: season is 0-based, episode is 0-based.
"""
prog = get_program_obj(item)
season = prog.get("tvSeasonNumber") xmltv_ns is zero-based.
episode = prog.get("tvSeasonEpisodeNumber") or prog.get("seriesEpisodeNumber") """
custom = get_custom(item)
season = custom.get("seasonNumber")
episode = custom.get("episodeNumber")
try: try:
season_i = int(season) if season is not None else None season_i = int(season) if season is not None else None
@@ -100,11 +159,24 @@ def add_episode_numbers(programme_el, item: dict):
if season_i is None and episode_i is None: if season_i is None and episode_i is None:
return return
# xmltv_ns is 0-based; assume provider values are 1-based if >= 1 s0 = (
s0 = (season_i - 1) if (season_i is not None and season_i >= 1) else 0 season_i - 1
e0 = (episode_i - 1) if (episode_i is not None and episode_i >= 1) else 0 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"
)
ep = ET.SubElement(programme_el, "episode-num", system="xmltv_ns")
if season_i is not None and episode_i is not None: if season_i is not None and episode_i is not None:
ep.text = f"{s0}.{e0}." ep.text = f"{s0}.{e0}."
elif season_i is not None: elif season_i is not None:
@@ -115,114 +187,270 @@ def add_episode_numbers(programme_el, item: dict):
def add_date(programme_el, item: dict): def add_date(programme_el, item: dict):
""" """
Add <date>YYYY</date> if we can find a year. Add <date>YYYY</date> using custom.airDate when available.
Try: program.year, else pubDate epoch-ms -> year.
""" """
prog = get_program_obj(item) custom = get_custom(item)
air_date = custom.get("airDate")
year = prog.get("year") if air_date is None:
year_out = None return
try: try:
if year is not None: dt = datetime.fromtimestamp(
y = int(year) int(air_date) / 1000,
if 1800 <= y <= 2100: tz=timezone.utc,
year_out = str(y) )
except (ValueError, TypeError):
year_out = None
if year_out is None: ET.SubElement(programme_el, "date").text = str(dt.year)
pub = prog.get("pubDate")
try:
if pub is not None:
dt = datetime.fromtimestamp(int(pub) / 1000, tz=timezone.utc)
year_out = str(dt.year)
except (ValueError, TypeError, OSError):
year_out = None
if year_out: except (ValueError, TypeError, OSError):
ET.SubElement(programme_el, "date").text = year_out pass
def add_length(programme_el, item: dict): def add_length(programme_el, item: dict):
""" """
Add <length units="seconds">N</length> from program.runtime if present. Add programme duration from custom.duration.
Univtec duration appears to be seconds.
""" """
prog = get_program_obj(item) custom = get_custom(item)
runtime = prog.get("runtime") duration = custom.get("duration")
if runtime is None:
if duration is None:
return return
try: try:
seconds = int(str(runtime).strip()) seconds = int(duration)
if seconds > 0: if seconds > 0:
ET.SubElement(programme_el, "length", units="seconds").text = str(seconds) ET.SubElement(
programme_el,
"length",
units="seconds"
).text = str(seconds)
except (ValueError, TypeError): 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 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(): def main():
run_time_utc = datetime.now(timezone.utc) run_time_utc = datetime.now(timezone.utc)
print(f"[{fmt_log_time(run_time_utc)}] Script run started (TZ={TZ_NAME})")
resp = requests.get(EPG_URL, params={"id": CHANNEL_ID}, timeout=30) print(
resp.raise_for_status() f"[{fmt_log_time(run_time_utc)}] "
data = resp.json() f"Script run started (TZ={TZ_NAME})"
)
if not isinstance(data, list) or not data: events = fetch_tvj_events()
raise RuntimeError("EPG response is empty or invalid (expected a non-empty list).")
# EPG "updated until" = furthest programme end time in returned schedule # Discard malformed entries and provider filler.
last_update_epoch = max(int(p["endTime"]) for p in data if "endTime" in p) valid_events = []
last_update_utc = datetime.fromtimestamp(last_update_epoch / 1000, tz=timezone.utc)
tv = ET.Element("tv", attrib={"generator-info-name": "1SpotMedia TVJ EPG (enriched)"}) 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 # Channel block
ch = ET.SubElement(tv, "channel", id=TVG_ID) ch = ET.SubElement(tv, "channel", id=TVG_ID)
ET.SubElement(ch, "display-name").text = CHANNEL_NAME
ET.SubElement(ch, "icon", src=CHANNEL_ICON) ET.SubElement(
ch,
"display-name",
).text = CHANNEL_NAME
ET.SubElement(
ch,
"icon",
src=CHANNEL_ICON,
)
# Programmes # Programmes
for item in data: for item, start_dt, end_dt in valid_events:
if "startTime" not in item or "endTime" not in item:
continue
programme = ET.SubElement( programme = ET.SubElement(
tv, tv,
"programme", "programme",
start=epoch_ms_to_xmltv(int(item["startTime"])), start=datetime_to_xmltv(start_dt),
stop=epoch_ms_to_xmltv(int(item["endTime"])), stop=datetime_to_xmltv(end_dt),
channel=TVG_ID, channel=TVG_ID,
) )
title = ET.SubElement(programme, "title", lang="en") title = (
title.text = (
clean_text(item.get("title")) clean_text(item.get("title"))
or clean_text(get_program_obj(item).get("title")) or clean_text(get_custom(item).get("seriesTitle"))
or "Unknown Program" or "Unknown Program"
) )
ET.SubElement(programme, "desc", lang="en").text = get_description(item) ET.SubElement(
programme,
"title",
lang="en",
).text = title
cat = get_category(item) ET.SubElement(
if cat: programme,
ET.SubElement(programme, "category", lang="en").text = cat "desc",
lang="en",
).text = get_description(item)
add_categories(programme, item)
add_episode_numbers(programme, item) add_episode_numbers(programme, item)
add_date(programme, item) add_date(programme, item)
add_length(programme, item) add_length(programme, item)
add_rating(programme, item)
add_icon(programme, item)
# Write XMLTV file (overwrites) # Ensure output directory exists.
ET.ElementTree(tv).write(OUTPUT_FILE, encoding="utf-8", xml_declaration=True) 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) done_utc = datetime.now(timezone.utc)
print(f"[{fmt_log_time(done_utc)}] XMLTV written: {OUTPUT_FILE}")
print( print(
f"Summary: Last run={fmt_log_time(run_time_utc)}, " 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)}" f"EPG updated until={fmt_log_time(last_update_utc)}"
) )
if __name__ == "__main__": if __name__ == "__main__":
main() main()