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>
189 lines
6.8 KiB
Python
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()
|