From 7ed2e2fb06066d9741091e77cec7303fe5e32577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xos=C3=A9?= Date: Thu, 12 Nov 2026 22:28:54 +0100 Subject: [PATCH] Programme guide for 12 stations, rebuilt every 6 hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BBC (RMS API), LBC News (epgshare01), Antena 1-3 (RTP JSON), RNE, Radio 3, Radio 5, Radio Clásica (RTVE guide pages) and WBUR (weekly schedule). IPTV Simple now reloads the list and guide hourly. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++ addon.xml | 2 +- resources/__init__.py | 0 resources/lib/__init__.py | 0 resources/lib/epg.py | 294 ++++++++++++++++++++++++++++++++++++++ service.py | 95 +++++++++--- 6 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 resources/__init__.py create mode 100644 resources/lib/__init__.py create mode 100644 resources/lib/epg.py diff --git a/README.md b/README.md index 59e9a63..178fa02 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,27 @@ station list changed. On the first run it turns on *Use channel numbers from backend* + *always*, so the frequencies show as channel numbers and KodiMov+ keeps Movistar's TV numbers. +## Programme guide + +`resources/lib/epg.py` builds `epg.xml` in the add-on's profile folder when +Kodi starts and then every 6 hours (30 minutes after a failed source). A +source that fails keeps its previous programmes (`guide.json`). IPTV Simple +reloads the list and the guide every hour, which is when Kodi takes it. + +| Stations | Source | +|---|---| +| BBC Radio 4, World Service, 5 Live | BBC RMS schedule API (`rms.api.bbc.co.uk`) | +| LBC News | epgshare01 UK guide (`epg_ripper_UK1.xml.gz`) | +| Antena 1, 2, 3 | RTP EPG JSON (`rtp.pt/EPG/json/rtp-channels-page/list-grid/radio/<1-3>/...`) | +| RNE, Radio 3, Radio 5, Radio Clásica | RTVE guide pages (`rtve.es/play/guia-rne//`) | +| WBUR | WBUR weekly schedule (`api.wbur.org/schedule`) | + +No usable source yet: CRTVG (built in JavaScript), SER (blocks scripts), +Onda Cero, esRadio, Radio Marca, WNYC, KQED, NPR, Times Radio, TSF, Observador +and the music stations. To add one, write a function in `epg.py` and map the +station's tvg-id in `CHANNELS`. Test with +`python3 -I resources/lib/epg.py /tmp/epg.xml`. + Uninstalling the add-on leaves that instance behind: remove it in IPTV Simple's settings (or delete the instance file). diff --git a/addon.xml b/addon.xml index 287cb7f..df3f516 100644 --- a/addon.xml +++ b/addon.xml @@ -15,7 +15,7 @@ Engade á sección Radio de Kodi as emisoras do dial FM de Vigo (co número de canal na súa frecuencia, incluídas as portuguesas de Valença) e emisoras de noticias de Reino Unido, Estados Unidos e Portugal. Crea a súa propia instancia de IPTV Simple e mantena ao día cando se actualiza o complemento. Añade a la sección Radio de Kodi las emisoras del dial FM de Vigo (con el número de canal en su frecuencia, incluidas las portuguesas de Valença) y emisoras de noticias de Reino Unido, Estados Unidos y Portugal. Crea su propia instancia de IPTV Simple y la mantiene al día cuando se actualiza el complemento. Adds the stations on Vigo's FM dial (numbered by frequency, including the Portuguese ones from Valença) and news stations from the UK, US and Portugal to Kodi's Radio section. It creates its own IPTV Simple instance and keeps it up to date when the add-on updates. - 1.0.0: first version, 38 Vigo stations and 12 news stations. + 1.0.0: first version, 38 Vigo stations and 12 news stations, with a programme guide (updated every 6 hours) for BBC Radio 4, World Service, 5 Live, LBC News, Antena 1-3, RNE, Radio 3, Radio 5, Radio Clásica and WBUR. all MIT https://git.anosanube.ovh/xocko/radios-vigo diff --git a/resources/__init__.py b/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/lib/__init__.py b/resources/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/lib/epg.py b/resources/lib/epg.py new file mode 100644 index 0000000..15c3027 --- /dev/null +++ b/resources/lib/epg.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +"""Builds an XMLTV programme guide for the stations that publish a schedule. + +Plain Python (no Kodi modules), so it can be tested outside Kodi: + python3 -I resources/lib/epg.py /tmp/epg.xml + +Each source returns programmes as dicts with UTC epoch 'start'/'stop', +'title' and optional 'subtitle', 'desc', 'category' and 'icon'. Times are +parsed by hand: datetime.strptime fails at random inside Kodi's Python. +""" +import calendar +import gzip +import html +import io +import json +import re +import time +import urllib.request +import xml.etree.ElementTree as ET + +UA = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'} +DAY = 86400 + +# tvg-id in radio.m3u -> (source, source's own id) +CHANNELS = { + 'bbc-radio-4': ('bbc', 'bbc_radio_fourfm'), + 'bbc-world-service': ('bbc', 'bbc_world_service'), + 'bbc-radio-5-live': ('bbc', 'bbc_radio_five_live'), + 'lbc-news': ('epgshare_uk', 'LBC.News.uk'), + 'antena-1': ('rtp', '1'), + 'antena-2': ('rtp', '2'), + 'antena-3': ('rtp', '3'), + 'rne-radio-nacional-galicia': ('rtve', 'rne'), + 'rne-radio-3': ('rtve', 'radio3'), + 'rne-radio-5-galicia': ('rtve', 'radio5'), + 'rne-radio-clasica': ('rtve', 'radioclasica'), + 'wbur-boston': ('wbur', None), +} + + +def get(url, timeout=30): + data = urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout).read() + return data + + +# --- time zones ------------------------------------------------------------- + +def _last_sunday(year, month): + last = calendar.monthrange(year, month)[1] + return last - (calendar.weekday(year, month, last) + 1) % 7 + + +def _nth_sunday(year, month, n): + first = (6 - calendar.weekday(year, month, 1)) % 7 + 1 + return first + 7 * (n - 1) + + +def utc_offset(zone, ts): + """Hours east of UTC at UTC epoch ts for 'madrid', 'lisbon' or 'boston'.""" + year = time.gmtime(ts).tm_year + if zone in ('madrid', 'lisbon'): # EU summer time: last Sunday of March to last Sunday of October, 01:00 UTC + start = calendar.timegm((year, 3, _last_sunday(year, 3), 1, 0, 0)) + end = calendar.timegm((year, 10, _last_sunday(year, 10), 1, 0, 0)) + base = 1 if zone == 'madrid' else 0 + return base + (1 if start <= ts < end else 0) + # US Eastern: second Sunday of March 02:00 EST to first Sunday of November 02:00 EDT + start = calendar.timegm((year, 3, _nth_sunday(year, 3, 2), 7, 0, 0)) + end = calendar.timegm((year, 11, _nth_sunday(year, 11, 1), 6, 0, 0)) + return -4 if start <= ts < end else -5 + + +def local_to_utc(zone, y, mo, d, h, mi, s=0): + naive = calendar.timegm((y, mo, d, h, mi, s)) + guess = naive - utc_offset(zone, naive) * 3600 + return naive - utc_offset(zone, guess) * 3600 + + +def local_date(zone, ts): + """(year, month, day, weekday) of UTC epoch ts in the zone.""" + t = time.gmtime(ts + utc_offset(zone, ts) * 3600) + return t.tm_year, t.tm_mon, t.tm_mday, t.tm_wday + + +def iso_utc(text): + """'2026-11-11T23:32:30Z' -> epoch.""" + n = [int(x) for x in re.findall(r'\d+', text)[:6]] + return calendar.timegm(tuple(n)) + + +def chain_stops(programmes, last_length=3600): + """Sources that give only start times: each programme ends when the next starts.""" + programmes.sort(key=lambda p: p['start']) + for p, nxt in zip(programmes, programmes[1:] + [None]): + p.setdefault('stop', nxt['start'] if nxt else p['start'] + last_length) + return [p for p in programmes if p['stop'] > p['start']] + + +def text(fragment): + return html.unescape(re.sub(r'<[^>]+>', '', fragment or '')).strip() + + +# --- sources ---------------------------------------------------------------- + +def bbc(service, now): + out = [] + for day in (now, now + DAY): + url = 'https://rms.api.bbc.co.uk/v2/experience/inline/schedules/{}/{}'.format( + service, time.strftime('%Y-%m-%d', time.gmtime(day))) + for module in json.loads(get(url))['data']: + for b in module.get('data') or []: + if b.get('type') != 'broadcast_summary': + continue + titles, synopses = b.get('titles') or {}, b.get('synopses') or {} + out.append({'start': iso_utc(b['start']), 'stop': iso_utc(b['end']), + 'title': titles.get('primary') or '', + 'subtitle': titles.get('secondary') or '', + 'desc': synopses.get('medium') or synopses.get('short') or '', + 'icon': (b.get('image_url') or '').replace('{recipe}', '480x270') or None}) + return out + + +def rtp(channel, now): + out = [] + for day in (now, now + DAY): + y, m, d, _ = local_date('lisbon', day) + url = 'https://www.rtp.pt/EPG/json/rtp-channels-page/list-grid/radio/{}/{}-{}-{}/lis'.format(channel, d, m, y) + result = json.loads(get(url))['result'] + for part in ('morning', 'afternoon', 'evening'): + for item in result.get(part) or []: + n = [int(x) for x in re.findall(r'\d+', item['date'])[:6]] + images = item.get('image') or [] + episode = item.get('episode') or {} + out.append({'start': local_to_utc('lisbon', *n), + 'title': text(item.get('name')), + 'subtitle': text(episode.get('title')), + 'desc': text(episode.get('sinopse')) or text(item.get('description')), + 'icon': images[-1]['src'] if images else None}) + return chain_stops(out) + + +RTVE_ITEM = re.compile( + r'data-inicio="(\d\d):(\d\d)".*?\s*(?:)?(\d\d):(\d\d)' + r'.*?([^<]*)\s*

([^<]*)

(.*?)' + r'.*?]*src="([^"]+)"', re.S) + + +def rtve(slug, now): + out = [] + for day, suffix in ((now, ''), (now + DAY, 'manana/')): + y, m, d, _ = local_date('madrid', day) + page = get('https://www.rtve.es/play/guia-rne/{}/{}'.format(slug, suffix)).decode('utf-8', 'replace') + midnight = local_to_utc('madrid', y, m, d, 0, 0) + previous = -1 + for h1, m1, h2, m2, category, title, rest, image in RTVE_ITEM.findall(page): + start = local_to_utc('madrid', y, m, d, int(h1), int(m1)) + if start < previous: # the page went past midnight + start += DAY + stop = local_to_utc('madrid', y, m, d, int(h2), int(m2)) + while stop <= start: + stop += DAY + previous = start + desc = re.search(r'

(.*?)

', rest, re.S) + out.append({'start': start, 'stop': stop, 'title': text(title), 'category': text(category), + 'desc': text(desc.group(1)) if desc else '', 'icon': html.unescape(image)}) + if not out or out[-1]['stop'] < midnight: + raise ValueError('no programmes on ' + slug) + return out + + +def wbur(_, now): + body = json.loads(get('https://api.wbur.org/schedule'))['body'] + out = [] + for day in (now - DAY, now, now + DAY, now + 2 * DAY): + y, m, d, wday = local_date('boston', day) + rows = body['saturday' if wday == 5 else 'sunday' if wday == 6 else 'weekday'] + for row in rows: + hh, mm, ampm = re.match(r'(\d+):(\d+)\s*([AP]M)', row['time']).groups() + hour = int(hh) % 12 + (12 if ampm == 'PM' else 0) + labels = [text(i.get('label')) for i in row.get('items') or [] if i.get('label')] + if labels: + out.append({'start': local_to_utc('boston', y, m, d, hour, int(mm)), 'title': ' / '.join(labels)}) + return chain_stops(out) + + +def epgshare_uk(channel_ids, now): + """All wanted channels from one download of epgshare01's UK guide.""" + wanted = set(channel_ids) + data = gzip.GzipFile(fileobj=io.BytesIO(get('https://epgshare01.online/epgshare01/epg_ripper_UK1.xml.gz', 120))) + out = dict((c, []) for c in wanted) + for _, el in ET.iterparse(data): + if el.tag == 'programme': + channel = el.get('channel') + if channel in wanted: + out[channel].append({'start': xmltv_time(el.get('start')), 'stop': xmltv_time(el.get('stop')), + 'title': el.findtext('title') or '', 'subtitle': el.findtext('sub-title') or '', + 'desc': el.findtext('desc') or ''}) + el.clear() + elif el.tag == 'channel': + el.clear() + return out + + +def xmltv_time(value): + n = re.match(r'(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)\s*([+-])(\d\d)(\d\d)', value).groups() + ts = calendar.timegm(tuple(int(x) for x in n[:6])) + offset = (int(n[7]) * 3600 + int(n[8]) * 60) * (1 if n[6] == '+' else -1) + return ts - offset + + +SOURCES = {'bbc': bbc, 'rtp': rtp, 'rtve': rtve, 'wbur': wbur} + + +# --- guide ------------------------------------------------------------------ + +def fetch_all(log=print, now=None): + """{tvg-id: [programmes]} for every channel whose source answered.""" + now = now or time.time() + guide = {} + for cid, (source, sid) in CHANNELS.items(): + if source not in SOURCES: + continue + try: + guide[cid] = SOURCES[source](sid, now) + except Exception as e: # one broken source mustn't stop the others + log('EPG {} ({}) failed: {}'.format(cid, source, e)) + shared = dict((sid, cid) for cid, (source, sid) in CHANNELS.items() if source == 'epgshare_uk') + if shared: + try: + for sid, programmes in epgshare_uk(list(shared), now).items(): + if programmes: + guide[shared[sid]] = programmes + except Exception as e: + log('EPG epgshare01 UK failed: {}'.format(e)) + return guide + + +def xmltv(guide, names): + def stamp(ts): + return time.strftime('%Y%m%d%H%M%S +0000', time.gmtime(ts)) + + def esc(s): + return html.escape(s or '', quote=True) + + lines = ['', ''] + for cid in sorted(guide): + lines.append(' {}'.format(esc(cid), esc(names.get(cid, cid)))) + for cid in sorted(guide): + for p in sorted(guide[cid], key=lambda p: p['start']): + lines.append(' '.format(stamp(p['start']), stamp(p['stop']), esc(cid))) + lines.append(' {}'.format(esc(p['title']))) + if p.get('subtitle'): + lines.append(' {}'.format(esc(p['subtitle']))) + if p.get('desc'): + lines.append(' {}'.format(esc(p['desc']))) + if p.get('category'): + lines.append(' {}'.format(esc(p['category']))) + if p.get('icon'): + lines.append(' '.format(esc(p['icon']))) + lines.append(' ') + lines.append('') + return '\n'.join(lines) + '\n' + + +def merge(old, new, now): + """New programmes, plus the old ones of channels whose source failed this + time, as long as they still reach into the future.""" + guide = dict(new) + for cid, programmes in (old or {}).items(): + if cid not in guide and any(p['stop'] > now for p in programmes): + guide[cid] = programmes + for cid in guide: # keep a few hours of the past, drop the rest + guide[cid] = [p for p in guide[cid] if p['stop'] > now - 6 * 3600] + return guide + + +def channel_names(m3u): + return dict(re.findall(r'tvg-id="([^"]+)".*,(.*)', m3u)) + + +if __name__ == '__main__': + import os + import sys + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + with io.open(os.path.join(root, 'radio.m3u'), encoding='utf-8') as f: + names = channel_names(f.read()) + g = fetch_all() + for cid in sorted(CHANNELS): + ps = g.get(cid, []) + first = time.strftime('%d %H:%M', time.gmtime(ps[0]['start'])) if ps else '-' + last = time.strftime('%d %H:%M', time.gmtime(max(p['stop'] for p in ps))) if ps else '-' + print('{:<28} {:>4} programmes {} -> {} UTC'.format(cid, len(ps), first, last)) + if len(sys.argv) > 1: + with io.open(sys.argv[1], 'w', encoding='utf-8') as f: + f.write(xmltv(g, names)) diff --git a/service.py b/service.py index 60c6a51..0255211 100644 --- a/service.py +++ b/service.py @@ -1,24 +1,34 @@ # -*- coding: utf-8 -*- -"""Keeps an IPTV Simple instance that plays this add-on's radio.m3u. +"""Keeps an IPTV Simple instance that plays this add-on's radio.m3u, and its +programme guide up to date. Runs when Kodi starts and again after every add-on update. Writes the instance settings, then restarts IPTV Simple (once nothing is playing) if the -settings or the station list changed since the last run. +settings or the station list changed since the last run. Rebuilds epg.xml +every 6 hours (30 minutes after a failure); IPTV Simple reloads the station +list and the guide every hour, which is when Kodi takes the new guide. """ import glob import hashlib import io import json import os +import threading +import time import xbmc import xbmcaddon import xbmcvfs +from resources.lib import epg + ADDON = xbmcaddon.Addon() ADDON_ID = ADDON.getAddonInfo('id') HOME = 'special://home/addons/{}/resources/'.format(ADDON_ID) +PROFILE = 'special://profile/addon_data/{}/'.format(ADDON_ID) FIRST_INSTANCE = 95 # KodiMov+ uses 91 (VAIO) and 93 (.49) +GUIDE_EVERY = 6 * 3600 +GUIDE_RETRY = 30 * 60 SETTINGS = ''' Radios de Vigo @@ -29,10 +39,12 @@ SETTINGS = ''' true 1 false - 0 + 1 + 60 0 - + {profile}epg.xml + true 0 {home}logos/ false @@ -40,7 +52,7 @@ SETTINGS = ''' true false -'''.format(home=HOME) +'''.format(home=HOME, profile=PROFILE) def log(msg): @@ -85,13 +97,35 @@ def restart_iptv_simple(): xbmc.sleep(3000) +def update_guide(profile, m3u): + """Writes epg.xml from every source that answers; channels whose source + failed keep their previous programmes. Returns True if no source failed.""" + failures = [] + now = time.time() + new = epg.fetch_all(log=lambda msg: (failures.append(msg), log(msg)), now=now) + cache_path = os.path.join(profile, 'guide.json') + try: + old = json.loads(read(cache_path) or '{}') + except ValueError: + old = {} + guide = epg.merge(old, new, now) + write(cache_path, json.dumps(guide)) + content = epg.xmltv(guide, epg.channel_names(m3u)) + epg_path = os.path.join(profile, 'epg.xml') + if read(epg_path) != content: + write(epg_path, content) + log('guide: {} stations, {} programmes'.format(len(guide), sum(len(p) for p in guide.values()))) + return not failures + + def main(): monitor, player = xbmc.Monitor(), xbmc.Player() - profile = xbmcvfs.translatePath(ADDON.getAddonInfo('profile')) + profile = xbmcvfs.translatePath(PROFILE) pvr_dir = xbmcvfs.translatePath('special://profile/addon_data/pvr.iptvsimple/') for d in (profile, pvr_dir): if not os.path.isdir(d): os.makedirs(d) + m3u = read(xbmcvfs.translatePath(HOME + 'radio.m3u')) or '' path = instance_file(pvr_dir) if read(path) != SETTINGS: @@ -108,19 +142,46 @@ def main(): write(numbers_set, '') log('turned on backend channel numbers') - m3u = read(xbmcvfs.translatePath(HOME + 'radio.m3u')) or '' - state = hashlib.sha1((SETTINGS + m3u).encode('utf-8')).hexdigest() - if read(os.path.join(profile, 'state')) == state: - return + # The guide is fetched in the background so Kodi can always stop this service quickly + guide_ok = [True] - while player.isPlaying(): # restarting IPTV Simple stops the radio or TV playing + def guide_job(): + try: + guide_ok[0] = update_guide(profile, m3u) + except Exception as e: + guide_ok[0] = False + log('guide update failed: {}'.format(e)) + + epg_path = os.path.join(profile, 'epg.xml') + job = threading.Thread(target=guide_job) + job.daemon = True + job.start() + + state = hashlib.sha1((SETTINGS + m3u).encode('utf-8')).hexdigest() + restart_pending = read(os.path.join(profile, 'state')) != state + if restart_pending and not os.path.exists(epg_path): + job.join(180) # first install: have a guide before IPTV Simple starts reading it + next_guide = time.time() + GUIDE_EVERY + + while not monitor.abortRequested(): + if restart_pending and not player.isPlaying(): # restarting IPTV Simple stops what's playing + if monitor.waitForAbort(5): + break + log('station list or settings changed, restarting IPTV Simple') + restart_iptv_simple() + write(os.path.join(profile, 'state'), state) + restart_pending = False + if not job.is_alive(): + if not guide_ok[0]: + next_guide = min(next_guide, time.time() + GUIDE_RETRY) + guide_ok[0] = True + if time.time() >= next_guide: + next_guide = time.time() + GUIDE_EVERY + job = threading.Thread(target=guide_job) + job.daemon = True + job.start() if monitor.waitForAbort(10): - return - if monitor.waitForAbort(5): - return - log('station list or settings changed, restarting IPTV Simple') - restart_iptv_simple() - write(os.path.join(profile, 'state'), state) + break if __name__ == '__main__':