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,3 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
output/*
|
||||
+2
-6
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
) &
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 & 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()
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility entry point for the EPG engine."""
|
||||
from epg.engine import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user