- Channel numbers: 1.0.0 set "use channel numbers from backend" before "always", which Kodi refuses with more than one PVR instance loaded, and wrote its marker anyway. Now it checks every 5 minutes: if the setting is hidden it turns "always" on, and it turns the setting on once (new marker numbers-on, so existing installs get one proper pass). It never turns anything off, and turning it off by hand afterwards is respected. - Takes turns with KodiMov+ to restart IPTV Simple, through the pvr.iptvsimple.restarting property of Kodi's home window. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
238 lines
9.0 KiB
Python
238 lines
9.0 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 xbmcgui
|
|
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
|
|
NUMBERS_EVERY = 5 * 60
|
|
# IPTV Simple is shared with KodiMov+ (its instance is the TV section).
|
|
# Restarting it restarts every instance, so the add-ons take turns: whoever
|
|
# restarts it says so in this property of Kodi's home window (the time it
|
|
# started), and the other waits. KodiMov+ uses the same name.
|
|
IPTV_RESTART_PROPERTY = 'pvr.iptvsimple.restarting'
|
|
|
|
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():
|
|
window = xbmcgui.Window(10000)
|
|
for _ in range(40): # KodiMov+ is restarting it: wait (a turn older than 20 s is stale)
|
|
try:
|
|
busy = 0 <= time.time() - float(window.getProperty(IPTV_RESTART_PROPERTY) or 0) < 20
|
|
except ValueError:
|
|
busy = False
|
|
if not busy:
|
|
break
|
|
xbmc.sleep(1000)
|
|
window.setProperty(IPTV_RESTART_PROPERTY, str(time.time()))
|
|
try:
|
|
for enabled in (False, True):
|
|
rpc('Addons.SetAddonEnabled', addonid='pvr.iptvsimple', enabled=enabled)
|
|
xbmc.sleep(3000)
|
|
finally:
|
|
window.clearProperty(IPTV_RESTART_PROPERTY)
|
|
|
|
|
|
def ensure_channel_numbers(marker):
|
|
"""Makes Kodi show the stations' own numbers (the Vigo ones are their FM
|
|
frequency) instead of 1, 2, 3...
|
|
|
|
Kodi only lets "use channel numbers from backend" be read or changed
|
|
while it applies: with exactly one PVR instance loaded, or with several
|
|
and the "always" setting on. This add-on's instance usually sits next to
|
|
KodiMov+'s, and which one came first changes what Kodi accepts, so this
|
|
runs now and then and puts it right:
|
|
- hidden: turns "always" on, which brings the stored value back;
|
|
- off and never turned on by this add-on (no marker file): turns it on;
|
|
- off after this add-on turned it on once: the user's choice, left alone.
|
|
It never turns anything off, so it can't undo what KodiMov+ needs for
|
|
Movistar's TV numbers. Returns True when Kodi is using the numbers."""
|
|
def numbers():
|
|
return (rpc('Settings.GetSettingValue', setting='pvrmanager.usebackendchannelnumbers').get('result') or {}).get('value')
|
|
value = numbers()
|
|
if value is None:
|
|
rpc('Settings.SetSettingValue', setting='pvrmanager.usebackendchannelnumbersalways', value=True)
|
|
value = numbers()
|
|
if value is None:
|
|
return False # IPTV Simple isn't loaded right now: try later
|
|
if value is False and not os.path.exists(marker):
|
|
rpc('Settings.SetSettingValue', setting='pvrmanager.usebackendchannelnumbers', value=True)
|
|
value = numbers()
|
|
if value is True and not os.path.exists(marker):
|
|
write(marker, '')
|
|
log('turned on backend channel numbers')
|
|
return value is True
|
|
|
|
|
|
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-on", not the "numbers-set" of 1.0.0: that one was written even
|
|
# when Kodi had refused the setting
|
|
numbers_on = os.path.join(profile, 'numbers-on')
|
|
next_numbers = time.time() + 30 # IPTV Simple has to be loaded first
|
|
|
|
# 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 time.time() >= next_numbers:
|
|
next_numbers = time.time() + NUMBERS_EVERY
|
|
ensure_channel_numbers(numbers_on)
|
|
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()
|