Files
radios-vigo/service.py
T
XoséandClaude Fable 5.2 333b8ef7ad 1.0.1: get along with KodiMov+
- 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>
2026-11-13 13:41:36 +01:00

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()