Files
radios-vigo/service.py
T
XoséandClaude Opus 5.5 7ed2e2fb06 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>
2026-11-12 22:28:54 +01:00

189 lines
6.8 KiB
Python

# -*- coding: utf-8 -*-
"""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. 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 = '''<settings version="2">
<setting id="kodi_addon_instance_name">Radios de Vigo</setting>
<setting id="kodi_addon_instance_enabled">true</setting>
<setting id="m3uPathType">0</setting>
<setting id="m3uPath">{home}radio.m3u</setting>
<setting id="m3uUrl" default="true" />
<setting id="m3uCache" default="true">true</setting>
<setting id="startNum" default="true">1</setting>
<setting id="numberByOrder" default="true">false</setting>
<setting id="m3uRefreshMode">1</setting>
<setting id="m3uRefreshIntervalMins">60</setting>
<setting id="epgPathType">0</setting>
<setting id="epgPath">{profile}epg.xml</setting>
<setting id="epgUrl" default="true" />
<setting id="epgCache" default="true">true</setting>
<setting id="logoPathType">0</setting>
<setting id="logoPath">{home}logos/</setting>
<setting id="useLogosLocalPathOnly" default="true">false</setting>
<setting id="logoFromEpg">0</setting>
<setting id="useFFmpegReconnect" default="true">true</setting>
<setting id="useInputstreamAdaptiveforHls" default="true">false</setting>
</settings>
'''.format(home=HOME, profile=PROFILE)
def log(msg):
xbmc.log('[{}] {}'.format(ADDON_ID, msg), xbmc.LOGINFO)
def rpc(method, **params):
return json.loads(xbmc.executeJSONRPC(json.dumps(
{'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params})))
def read(path):
try:
with io.open(path, 'r', encoding='utf-8') as f:
return f.read()
except (IOError, OSError):
return None
def write(path, content):
tmp = path + '.tmp'
with io.open(tmp, 'w', encoding='utf-8', newline='') as f:
f.write(content)
os.replace(tmp, path)
def instance_file(pvr_dir):
"""Our instance file: the one that already reads radio.m3u, else the
first free number from FIRST_INSTANCE on."""
for path in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
if HOME + 'radio.m3u' in (read(path) or ''):
return path
n = FIRST_INSTANCE
while os.path.exists(os.path.join(pvr_dir, 'instance-settings-{}.xml'.format(n))):
n += 1
return os.path.join(pvr_dir, 'instance-settings-{}.xml'.format(n))
def restart_iptv_simple():
for enabled in (False, True):
rpc('Addons.SetAddonEnabled', addonid='pvr.iptvsimple', enabled=enabled)
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(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:
write(path, SETTINGS)
log('wrote ' + path)
numbers_set = os.path.join(profile, 'numbers-set')
if not os.path.exists(numbers_set):
# Show the stations' own numbers (Vigo ones are their FM frequency).
# With more than one IPTV Simple instance Kodi ignores them unless
# "always" is on too; that also keeps KodiMov+'s TV numbers.
for setting in ('pvrmanager.usebackendchannelnumbers', 'pvrmanager.usebackendchannelnumbersalways'):
rpc('Settings.SetSettingValue', setting=setting, value=True)
write(numbers_set, '')
log('turned on backend channel numbers')
# The guide is fetched in the background so Kodi can always stop this service quickly
guide_ok = [True]
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):
break
if __name__ == '__main__':
main()