Add health reporting, container probe, and JNN channel support
Build and Publish TVJ EPG Image / build (push) Successful in 29s

This commit is contained in:
Lyncoln
2026-09-10 10:38:21 -04:00
parent 02a68f029a
commit 8529fca032
11 changed files with 349 additions and 13 deletions
+7
View File
@@ -17,6 +17,13 @@ BUILTINS = {
icon="https://rjr.vod.immergo.tv/rjr/uploaded/85c62d19-8e57-49b5-820a-acca5adcc1c9.png",
group="Sports",
),
"jnn": Channel(
id="JNN.jm@SD", name="Jamaica News Network",
channel_id="66e0a79714ec780025bae4fd",
stream_url="https://rjrjnn-ioriver-cdn.encoders.immergo.tv/2/streamPlaylist.m3u8",
icon="https://rjr.vod.immergo.tv/rjr/uploaded/3c3487a8-5e77-4c3a-abb0-8a5eca477e86.png",
group="News",
),
}
+35 -7
View File
@@ -1,7 +1,8 @@
"""Fetch each provider feed once and publish selected channels."""
from datetime import datetime
from datetime import datetime, timezone
import sys
from epg.config import load
from epg.health import record
from epg.providers import PROVIDERS
from epg.writers import atomic_write, m3u, xmltv
@@ -10,17 +11,44 @@ def run(environ=None):
channels, xml_path, m3u_path = load(environ)
feeds = {}
schedules = {}
report = {'checked_at': datetime.now(timezone.utc).isoformat(),
'success': False, 'providers': {}, 'channels': {}}
errors = []
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)
try:
feeds[key] = provider.fetch(channel.feed_url, channel.tenant)
report['providers'][channel.provider] = {'status': 'healthy'}
except Exception as error:
feeds[key] = error
report['providers'][channel.provider] = {
'status': 'unhealthy', 'error': type(error).__name__}
try:
if isinstance(feeds[key], Exception):
raise feeds[key]
schedules[channel.id] = provider.programmes(feeds[key], channel.channel_id)
report['channels'][channel.id] = {'status': 'healthy'}
except Exception as error:
errors.append(error)
report['channels'][channel.id] = {
'status': 'unhealthy', 'error': type(error).__name__}
if errors:
record(xml_path, report)
raise errors[0]
# 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)
try:
xml_content = xmltv(channels, schedules)
playlist = m3u(channels)
atomic_write(xml_path, xml_content)
atomic_write(m3u_path, playlist)
report['success'] = True
except Exception as error:
report['output_error'] = type(error).__name__
raise
finally:
record(xml_path, report)
print(f"[{datetime.now().astimezone().isoformat()}] Wrote {xml_path} and {m3u_path}", flush=True)
for channel in channels:
programmes = schedules[channel.id]
+85
View File
@@ -0,0 +1,85 @@
"""Health from local update results and the files actually served to clients."""
import json
import os
from datetime import datetime, timedelta, timezone
import xml.etree.ElementTree as ET
from epg.config import load
from epg.writers import atomic_write
def state_path(xml_path):
return xml_path.with_suffix('.health.json')
def record(xml_path, report):
atomic_write(state_path(xml_path), json.dumps(report).encode())
def evaluate(environ=None, now=None):
channels, xml_path, m3u_path = load(environ)
now = now or datetime.now(timezone.utc)
try:
report = json.loads(state_path(xml_path).read_text())
if not isinstance(report, dict):
raise ValueError('Invalid health state')
except (OSError, ValueError):
report = {}
outputs = {'xmltv': False, 'm3u': False}
root = None
playlist = ''
try:
root = ET.parse(xml_path).getroot()
outputs['xmltv'] = root.tag == 'tv'
except (OSError, ET.ParseError):
pass
try:
playlist = m3u_path.read_text()
outputs['m3u'] = playlist.startswith('#EXTM3U\n')
except (OSError, UnicodeError):
pass
details = {}
for channel in channels:
intervals = []
declared = False
if outputs['xmltv']:
declared = any(c.get('id') == channel.id for c in root.findall('channel'))
for p in root.findall('programme'):
if p.get('channel') != channel.id:
continue
try:
start, stop = (datetime.strptime(p.get(k, ''), '%Y%m%d%H%M%S %z') for k in ('start', 'stop'))
if stop > start and p.findtext('title'):
intervals.append((start, stop))
except ValueError:
pass
current = any(start <= now < stop + timedelta(seconds=1) for start, stop in intervals)
future = any(stop > now for _, stop in intervals)
lines = playlist.splitlines()
in_playlist = any(
line.startswith('#EXTINF:') and f'tvg-id="{channel.id}"' in line
and i + 1 < len(lines) and lines[i + 1] == channel.stream_url
for i, line in enumerate(lines)
)
usable = declared and current and future and outputs['m3u'] and in_playlist
details[channel.id] = {
'name': channel.name, 'status': 'healthy' if usable else 'unhealthy',
'programmes': len(intervals), 'covers_now': current, 'has_future_data': future,
'in_playlist': in_playlist,
'guide_until': max((stop for _, stop in intervals), default=None).isoformat() if intervals else None,
'last_update': report.get('channels', {}).get(channel.id),
}
env = os.environ if environ is None else environ
try:
last_attempt = datetime.fromisoformat(report['checked_at'])
overdue = now - last_attempt > timedelta(hours=int(env.get('UPDATE_INTERVAL', '6')), minutes=5)
except (KeyError, ValueError, TypeError):
overdue = True
usable = all(c['status'] == 'healthy' for c in details.values()) and all(outputs.values())
status = 'unhealthy' if not usable else ('degraded' if overdue or not report.get('success') else 'healthy')
return {
'status': status, 'checked_at': now.isoformat(),
'last_attempt': report.get('checked_at'), 'update_overdue': overdue,
'last_update_success': report.get('success', False),
'providers': report.get('providers', {}), 'channels': details, 'outputs': outputs,
'output_error': report.get('output_error'),
}
+16
View File
@@ -0,0 +1,16 @@
"""Container probe: degraded service remains usable and passes the probe."""
import os
import sys
import urllib.request
def main():
try:
with urllib.request.urlopen(f'http://127.0.0.1:{os.environ.get("PORT", "1876")}/health', timeout=5) as response:
return 0 if response.status == 200 else 1
except Exception:
return 1
if __name__ == '__main__':
sys.exit(main())
+41
View File
@@ -0,0 +1,41 @@
"""Serve generated files and a live, locally computed health endpoint."""
import json
import os
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
from epg.health import evaluate
class Handler(SimpleHTTPRequestHandler):
def do_GET(self):
if urlsplit(self.path).path.rstrip('/') == '/health':
self.health()
else:
super().do_GET()
def do_HEAD(self):
if urlsplit(self.path).path.rstrip('/') == '/health':
self.health(head=True)
else:
super().do_HEAD()
def health(self, head=False):
try:
report = evaluate()
except Exception:
report = {'status': 'unhealthy', 'error': 'Health evaluation failed'}
body = json.dumps(report).encode()
self.send_response(503 if report['status'] == 'unhealthy' else 200)
self.send_header('Content-Type', 'application/json')
self.send_header('Cache-Control', 'no-store')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
if not head:
self.wfile.write(body)
if __name__ == '__main__':
server = ThreadingHTTPServer(('', int(os.environ.get('PORT', '1876'))),
partial(Handler, directory='/app/output'))
server.serve_forever()