Programme guide for 12 stations, rebuilt every 6 hours
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
927195e28d
commit
7ed2e2fb06
6 files changed
+391
-15
No files matched your search
@@ -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+
|
backend* + *always*, so the frequencies show as channel numbers and KodiMov+
|
||||||
keeps Movistar's TV numbers.
|
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/<slug>/`) |
|
||||||
|
| 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
|
Uninstalling the add-on leaves that instance behind: remove it in
|
||||||
IPTV Simple's settings (or delete the instance file).
|
IPTV Simple's settings (or delete the instance file).
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<description lang="gl_ES">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.</description>
|
<description lang="gl_ES">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.</description>
|
||||||
<description lang="es_ES">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.</description>
|
<description lang="es_ES">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.</description>
|
||||||
<description lang="en_GB">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.</description>
|
<description lang="en_GB">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.</description>
|
||||||
<news>1.0.0: first version, 38 Vigo stations and 12 news stations.</news>
|
<news>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.</news>
|
||||||
<platform>all</platform>
|
<platform>all</platform>
|
||||||
<license>MIT</license>
|
<license>MIT</license>
|
||||||
<source>https://git.anosanube.ovh/xocko/radios-vigo</source>
|
<source>https://git.anosanube.ovh/xocko/radios-vigo</source>
|
||||||
|
|||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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)".*?<span class="datfin">\s*(?:<!-- -->)?(\d\d):(\d\d)</span>'
|
||||||
|
r'.*?<span class="pretitle">([^<]*)</span>\s*<h2 class="maintitle">([^<]*)</h2>(.*?)</div>'
|
||||||
|
r'.*?<img [^>]*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'<p>(.*?)</p>', 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 = ['<?xml version="1.0" encoding="UTF-8"?>', '<tv generator-info-name="service.radios.vigo">']
|
||||||
|
for cid in sorted(guide):
|
||||||
|
lines.append(' <channel id="{}"><display-name>{}</display-name></channel>'.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(' <programme start="{}" stop="{}" channel="{}">'.format(stamp(p['start']), stamp(p['stop']), esc(cid)))
|
||||||
|
lines.append(' <title>{}</title>'.format(esc(p['title'])))
|
||||||
|
if p.get('subtitle'):
|
||||||
|
lines.append(' <sub-title>{}</sub-title>'.format(esc(p['subtitle'])))
|
||||||
|
if p.get('desc'):
|
||||||
|
lines.append(' <desc>{}</desc>'.format(esc(p['desc'])))
|
||||||
|
if p.get('category'):
|
||||||
|
lines.append(' <category>{}</category>'.format(esc(p['category'])))
|
||||||
|
if p.get('icon'):
|
||||||
|
lines.append(' <icon src="{}"/>'.format(esc(p['icon'])))
|
||||||
|
lines.append(' </programme>')
|
||||||
|
lines.append('</tv>')
|
||||||
|
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))
|
||||||
+75
-14
@@ -1,24 +1,34 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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
|
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
|
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 glob
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcaddon
|
import xbmcaddon
|
||||||
import xbmcvfs
|
import xbmcvfs
|
||||||
|
|
||||||
|
from resources.lib import epg
|
||||||
|
|
||||||
ADDON = xbmcaddon.Addon()
|
ADDON = xbmcaddon.Addon()
|
||||||
ADDON_ID = ADDON.getAddonInfo('id')
|
ADDON_ID = ADDON.getAddonInfo('id')
|
||||||
HOME = 'special://home/addons/{}/resources/'.format(ADDON_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)
|
FIRST_INSTANCE = 95 # KodiMov+ uses 91 (VAIO) and 93 (.49)
|
||||||
|
GUIDE_EVERY = 6 * 3600
|
||||||
|
GUIDE_RETRY = 30 * 60
|
||||||
|
|
||||||
SETTINGS = '''<settings version="2">
|
SETTINGS = '''<settings version="2">
|
||||||
<setting id="kodi_addon_instance_name">Radios de Vigo</setting>
|
<setting id="kodi_addon_instance_name">Radios de Vigo</setting>
|
||||||
@@ -29,10 +39,12 @@ SETTINGS = '''<settings version="2">
|
|||||||
<setting id="m3uCache" default="true">true</setting>
|
<setting id="m3uCache" default="true">true</setting>
|
||||||
<setting id="startNum" default="true">1</setting>
|
<setting id="startNum" default="true">1</setting>
|
||||||
<setting id="numberByOrder" default="true">false</setting>
|
<setting id="numberByOrder" default="true">false</setting>
|
||||||
<setting id="m3uRefreshMode" default="true">0</setting>
|
<setting id="m3uRefreshMode">1</setting>
|
||||||
|
<setting id="m3uRefreshIntervalMins">60</setting>
|
||||||
<setting id="epgPathType">0</setting>
|
<setting id="epgPathType">0</setting>
|
||||||
<setting id="epgPath" default="true" />
|
<setting id="epgPath">{profile}epg.xml</setting>
|
||||||
<setting id="epgUrl" default="true" />
|
<setting id="epgUrl" default="true" />
|
||||||
|
<setting id="epgCache" default="true">true</setting>
|
||||||
<setting id="logoPathType">0</setting>
|
<setting id="logoPathType">0</setting>
|
||||||
<setting id="logoPath">{home}logos/</setting>
|
<setting id="logoPath">{home}logos/</setting>
|
||||||
<setting id="useLogosLocalPathOnly" default="true">false</setting>
|
<setting id="useLogosLocalPathOnly" default="true">false</setting>
|
||||||
@@ -40,7 +52,7 @@ SETTINGS = '''<settings version="2">
|
|||||||
<setting id="useFFmpegReconnect" default="true">true</setting>
|
<setting id="useFFmpegReconnect" default="true">true</setting>
|
||||||
<setting id="useInputstreamAdaptiveforHls" default="true">false</setting>
|
<setting id="useInputstreamAdaptiveforHls" default="true">false</setting>
|
||||||
</settings>
|
</settings>
|
||||||
'''.format(home=HOME)
|
'''.format(home=HOME, profile=PROFILE)
|
||||||
|
|
||||||
|
|
||||||
def log(msg):
|
def log(msg):
|
||||||
@@ -85,13 +97,35 @@ def restart_iptv_simple():
|
|||||||
xbmc.sleep(3000)
|
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():
|
def main():
|
||||||
monitor, player = xbmc.Monitor(), xbmc.Player()
|
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/')
|
pvr_dir = xbmcvfs.translatePath('special://profile/addon_data/pvr.iptvsimple/')
|
||||||
for d in (profile, pvr_dir):
|
for d in (profile, pvr_dir):
|
||||||
if not os.path.isdir(d):
|
if not os.path.isdir(d):
|
||||||
os.makedirs(d)
|
os.makedirs(d)
|
||||||
|
m3u = read(xbmcvfs.translatePath(HOME + 'radio.m3u')) or ''
|
||||||
|
|
||||||
path = instance_file(pvr_dir)
|
path = instance_file(pvr_dir)
|
||||||
if read(path) != SETTINGS:
|
if read(path) != SETTINGS:
|
||||||
@@ -108,19 +142,46 @@ def main():
|
|||||||
write(numbers_set, '')
|
write(numbers_set, '')
|
||||||
log('turned on backend channel numbers')
|
log('turned on backend channel numbers')
|
||||||
|
|
||||||
m3u = read(xbmcvfs.translatePath(HOME + 'radio.m3u')) or ''
|
# The guide is fetched in the background so Kodi can always stop this service quickly
|
||||||
state = hashlib.sha1((SETTINGS + m3u).encode('utf-8')).hexdigest()
|
guide_ok = [True]
|
||||||
if read(os.path.join(profile, 'state')) == state:
|
|
||||||
return
|
|
||||||
|
|
||||||
while player.isPlaying(): # restarting IPTV Simple stops the radio or TV playing
|
def guide_job():
|
||||||
if monitor.waitForAbort(10):
|
try:
|
||||||
return
|
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):
|
if monitor.waitForAbort(5):
|
||||||
return
|
break
|
||||||
log('station list or settings changed, restarting IPTV Simple')
|
log('station list or settings changed, restarting IPTV Simple')
|
||||||
restart_iptv_simple()
|
restart_iptv_simple()
|
||||||
write(os.path.join(profile, 'state'), state)
|
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):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Reference in new issue
Block a user