diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef66e5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +output/* diff --git a/Containerfile b/Containerfile index cb4ffac..a3daed9 100644 --- a/Containerfile +++ b/Containerfile @@ -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"] diff --git a/README.md b/README.md index 8921bd5..41f9ef1 100644 --- a/README.md +++ b/README.md @@ -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**. - - diff --git a/entrypoint.sh b/entrypoint.sh index 07f3af8..187d384 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 ) & diff --git a/epg/__init__.py b/epg/__init__.py new file mode 100644 index 0000000..2e56338 --- /dev/null +++ b/epg/__init__.py @@ -0,0 +1 @@ +"""Configurable XMLTV and M3U engine.""" diff --git a/epg/config.py b/epg/config.py new file mode 100644 index 0000000..b6d1afe --- /dev/null +++ b/epg/config.py @@ -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 diff --git a/epg/engine.py b/epg/engine.py new file mode 100644 index 0000000..0790a41 --- /dev/null +++ b/epg/engine.py @@ -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() diff --git a/epg/models.py b/epg/models.py new file mode 100644 index 0000000..6ce5a76 --- /dev/null +++ b/epg/models.py @@ -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, ...] = () diff --git a/epg/providers/__init__.py b/epg/providers/__init__.py new file mode 100644 index 0000000..43604e9 --- /dev/null +++ b/epg/providers/__init__.py @@ -0,0 +1,4 @@ +"""Provider plugins implement fetch(url, tenant) and programmes(data, channel_id).""" +from epg.providers import univtec + +PROVIDERS = {"univtec": univtec} diff --git a/tvj_epg.py b/epg/providers/univtec.py similarity index 52% rename from tvj_epg.py rename to epg/providers/univtec.py index d1aa544..0845f55 100644 --- a/tvj_epg.py +++ b/epg/providers/univtec.py @@ -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() \ No newline at end of file + # 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) diff --git a/epg/writers.py b/epg/writers.py new file mode 100644 index 0000000..9258431 --- /dev/null +++ b/epg/writers.py @@ -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) diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..bf5a9bf --- /dev/null +++ b/tests/test_engine.py @@ -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": "

Live & replay

", "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() diff --git a/tvj_epj.py b/tvj_epj.py new file mode 100644 index 0000000..21f0eb9 --- /dev/null +++ b/tvj_epj.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Compatibility entry point for the EPG engine.""" +from epg.engine import main + +if __name__ == "__main__": + main()