Refactor EPG into shared engine with configurable TVJ channels
Build and Publish TVJ EPG Image / build (push) Successful in 29s

This commit is contained in:
Lyncoln
2026-09-10 09:49:40 -04:00
parent e6f8b0e512
commit af59324d99
13 changed files with 348 additions and 312 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
output/*
+2 -6
View File
@@ -1,16 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY tvj_epg.py .
COPY tvj.m3u /app/output/tvj.m3u
COPY epg ./epg
COPY tvj_epj.py .
RUN pip install --no-cache-dir requests
ENV UPDATE_INTERVAL=6
EXPOSE 8787
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+68 -84
View File
@@ -1,99 +1,83 @@
# TVJ IPTV + XMLTV Container
This project provides a **self-contained IPTV helper container** that:
A configurable EPG engine that fetches channel schedules and serves a combined
XMLTV guide and M3U playlist over HTTP. TVJ and TVJ Sports Network share a Univtec
provider plugin; the engine fetches their common feed once per update.
* Serves a **TVJ M3U playlist** (stream data)
* Automatically fetches and updates **XMLTV EPG data** for Television Jamaica (TVJ)
* Hosts both files over HTTP for easy consumption by IPTV clients
## Run
The container is designed to run unattended and continuously refresh the EPG on a schedule.
Build this checkout and select both channels:
---
## What this container provides
When deployed, the container exposes the following URLs:
* **Playlist (M3U):**
`http://localhost:8787/tvj.m3u`
* **EPG (XMLTV):**
`http://localhost:8787/tvj.xml`
These can be used directly in:
* Plex
* Jellyfin
* Kodi
* Tivimate
* TVHeadend
* Any XMLTV / M3U compatible IPTV client
The playlist and EPG are matched using:
```
tvg-id="TVJ.jm@SD"
```
---
### Run the container
```bash
podman run -d \
--name tvj-epg \
-p 8787:8787 \
```sh
podman build -t tvj-epg .
podman run -d --name tvj-epg -p 8787:8787 \
-e TZ=America/Jamaica \
-e CHANNELS=tvj,tvjsn \
-e UPDATE_INTERVAL=3 \
ghcr.io/lyncolnmd/tvj-epg:latest
tvj-epg
```
#### Environment variables
With no `CHANNELS` setting, only TVJ is included, preserving the default behavior.
Use `CHANNELS=tvjsn` for TVJ Sports Network alone.
| Variable | Description |
| ----------------- | ------------------------------------------------------ |
| `TZ` | Timezone used **for logging only** (XMLTV remains UTC) |
| `UPDATE_INTERVAL` | How often the EPG is refreshed (hours) |
- Playlist: `http://localhost:8787/tvj.m3u`
- Guide: `http://localhost:8787/tvj.xml`
---
Both files contain the selected channels. Their XMLTV IDs and playlist `tvg-id`
values match automatically. Outputs become available after the first successful
update. Stream access is determined by the stream provider.
## IPTV client configuration
## Configuration
### Playlist URL
| Variable | Default | Meaning |
| --- | --- | --- |
| `CHANNELS` | `tvj` | Comma-separated built-in channel keys; unknown keys fail startup |
| `UPDATE_INTERVAL` | `6` | Positive integer refresh interval in hours |
| `TZ` | Container timezone | Logging timezone; XMLTV timestamps remain UTC |
| `OUTPUT_FILE` | `/app/output/tvj.xml` | XMLTV output path |
| `M3U_OUTPUT_FILE` | `tvj.m3u` beside XMLTV output | Playlist output path |
The container serves `/app/output`; keep output paths there to expose the files
through its HTTP server.
| Channel key | Name | XMLTV / M3U ID |
| --- | --- | --- |
| `tvj` | Television Jamaica | `TVJ.jm@SD` |
| `tvjsn` | TVJ Sports Network | `TVJSN.jm@SD` |
TVJ Sports Network uses the supplied video URL:
`https://vod2live.univtec.com/manifest/65cf631b-d422-49e0-ab25-07d5aa08ca8f/7660000.m3u8`.
## Architecture
- `epg/engine.py`: orchestrates configuration, shared fetching, and output.
- `epg/config.py`: built-in channel definitions and environment selection.
- `epg/models.py`: common channel and programme models.
- `epg/providers/`: provider adapters registered in `PROVIDERS`.
- `epg/writers.py`: XMLTV and M3U generation and atomic file replacement.
- `tvj_epj.py`: compatibility entry point for running the engine directly.
Add channels using an existing provider by adding a definition to `BUILTINS`.
A new provider adapter implements `fetch(url, tenant)` and
`programmes(data, channel_id)`, returning normalized `Programme` objects.
Optional enriched metadata is represented as XMLTV elements, preserving categories,
episode numbers, dates, durations, ratings, and artwork from the original script.
Univtec channel IDs select objects within the response; they are not URL suffixes.
All selected schedules must parse successfully before outputs are replaced.
A failed fetch or invalid schedule leaves previous files intact, and the container
retries at the next interval. Each file is replaced atomically; the two files are
not a single transaction. Old data can expire during prolonged outages.
Custom feed/channel environment settings, per-channel cached fallback, and health
reporting are planned for subsequent milestones; they are not implemented yet.
The root `tvj.m3u` is a legacy static example; container playlists are now generated.
## Local verification
With Python 3.11+ and `requests` installed:
```sh
python3 -m unittest discover -s tests -v
CHANNELS=tvj,tvjsn OUTPUT_FILE=output/tvj.xml python3 -m epg.engine
```
http://localhost:8787/tvj.m3u
```
### EPG URL
```
http://localhost:8787/tvj.xml
```
Most clients will automatically match the guide using the `tvg-id`.
---
## Logging and updates
Container logs show:
* When the script last ran
* When the XML file was written
* How far into the future the EPG data extends
Example:
```
[2026-02-01 10:46:11 EST] Script run started (TZ=America/Jamaica)
[2026-02-01 10:46:11 EST] XMLTV written: /app/output/tvj.xml
Summary: Last run=2026-02-01 10:46:11 EST, EPG updated until=2026-02-02 10:34:04 EST
```
---
## Updating the container
The container image is built automatically via **GitHub Actions**.
+8 -3
View File
@@ -3,11 +3,16 @@ set -eu
mkdir -p /app/output
UPDATE_INTERVAL="${UPDATE_INTERVAL:-6}"
export UPDATE_INTERVAL
UPDATE_INTERVAL="$(python -c 'import os; value = os.environ["UPDATE_INTERVAL"]; assert value.isascii() and value.isdigit() and int(value) > 0, "UPDATE_INTERVAL must be a positive integer (hours)"; print(int(value))')"
# Fail fast on invalid channel/output configuration.
python -c 'from epg.config import load; load()'
( while true; do
echo "$(date) - Updating TVJ EPG..."
python /app/tvj_epg.py
echo "$(date) - Sleeping for ${UPDATE_INTERVAL} hours..."
echo "$(date) - Updating EPG and playlist..."
if ! python -m epg.engine; then
echo "$(date) - Update failed; retrying after ${UPDATE_INTERVAL} hours." >&2
fi
sleep "$((UPDATE_INTERVAL * 3600))"
done
) &
+1
View File
@@ -0,0 +1 @@
"""Configurable XMLTV and M3U engine."""
+35
View File
@@ -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
+39
View File
@@ -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()
+27
View File
@@ -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, ...] = ()
+4
View File
@@ -0,0 +1,4 @@
"""Provider plugins implement fetch(url, tenant) and programmes(data, channel_id)."""
from epg.providers import univtec
PROVIDERS = {"univtec": univtec}
+38 -219
View File
@@ -1,51 +1,10 @@
#!/usr/bin/env python3
import os
import re
"""Univtec feed adapter and metadata normalization."""
import html
import requests
import xml.etree.ElementTree as ET
import re
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")
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."""
@@ -203,7 +162,7 @@ def add_date(programme_el, item: dict):
ET.SubElement(programme_el, "date").text = str(dt.year)
except (ValueError, TypeError, OSError):
except (ValueError, TypeError, OSError, OverflowError):
pass
@@ -267,190 +226,50 @@ def add_icon(programme_el, item: dict):
)
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()
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 RuntimeError(
"EPG response is empty or invalid "
"(expected a non-empty list)."
)
raise ValueError("Expected a non-empty Univtec channel list")
return data
channel = next(
(
item
for item in data
if item.get("id") == CHANNEL_ID
),
None,
)
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 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")
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 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 = []
raise ValueError(f"Channel {channel_id} has no valid events list")
result = []
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):
start = parse_iso8601(item.get("start"))
stop = parse_iso8601(item.get("end"))
except (ValueError, TypeError, AttributeError):
continue
if end_dt <= start_dt:
if stop <= start:
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()
# 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)
+52
View File
@@ -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)
+65
View File
@@ -0,0 +1,65 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import xml.etree.ElementTree as ET
from epg.config import BUILTINS, load
from epg.engine import run
from epg.providers import univtec
def feed():
return [{"id": c.channel_id, "epgObject": {"events": [
{"title": "Match & highlights", "start": "2026-09-10T12:00:00Z",
"end": "2026-09-10T13:00:00Z", "custom": {
"longDescription": "<p>Live &amp; replay</p>", "genre": ["Sports"],
"seasonNumber": 2, "episodeNumber": 3, "rating": "G"}},
{"title": "No Program", "start": "2026-09-10T13:00:00Z", "end": "2026-09-10T14:00:00Z"},
{"title": "Broken", "start": 123, "end": None},
]}} for c in BUILTINS.values()]
class EngineTests(unittest.TestCase):
def test_selection_validation(self):
self.assertEqual([c.id for c in load({})[0]], [BUILTINS['tvj'].id])
self.assertEqual(len(load({'CHANNELS': 'tvjsn,tvjsn'})[0]), 1)
for selection in ('', 'unknown'):
with self.assertRaises(ValueError):
load({'CHANNELS': selection})
def test_outputs_and_shared_fetch(self):
for selection in ('tvj', 'tvjsn', 'tvj,tvjsn'):
with self.subTest(selection=selection), tempfile.TemporaryDirectory() as directory:
output = Path(directory) / 'tvj.xml'
with patch.object(univtec, 'fetch', return_value=feed()) as fetch:
run({'CHANNELS': selection, 'OUTPUT_FILE': str(output)})
fetch.assert_called_once()
root = ET.parse(output).getroot()
ids = [BUILTINS[k].id for k in selection.split(',')]
self.assertEqual([c.get('id') for c in root.findall('channel')], ids)
self.assertEqual([p.get('channel') for p in root.findall('programme')], ids)
playlist = output.with_suffix('.m3u').read_text()
for key in selection.split(','):
self.assertIn(f'tvg-id="{BUILTINS[key].id}"', playlist)
self.assertIn(BUILTINS[key].stream_url, playlist)
self.assertEqual(root.findtext('programme/desc'), 'Live & replay')
self.assertEqual(root.findtext('programme/episode-num'), '1.2.')
self.assertEqual(root.findtext('programme/category'), 'Sports')
def test_failed_update_preserves_files(self):
for response in ([], feed()[:1]):
with tempfile.TemporaryDirectory() as directory:
xml = Path(directory) / 'tvj.xml'
playlist = Path(directory) / 'tvj.m3u'
xml.write_text('previous guide')
playlist.write_text('previous playlist')
with patch.object(univtec, 'fetch', return_value=response):
with self.assertRaises(ValueError):
run({'CHANNELS': 'tvj,tvjsn', 'OUTPUT_FILE': str(xml)})
self.assertEqual(xml.read_text(), 'previous guide')
self.assertEqual(playlist.read_text(), 'previous playlist')
if __name__ == '__main__':
unittest.main()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
"""Compatibility entry point for the EPG engine."""
from epg.engine import main
if __name__ == "__main__":
main()