Files
kodimovplus/resources/lib/kodimov.py
T
XoséandClaude Opus 5.5 69a37ff064 1.0.0+kodimov.12: no duplicate TV guide instance; retry the web player registration
- The TV guide setup always wrote instance-settings-91.xml, so on a box
  already set up with another instance (from the original add-on) both read
  channels.m3u8 and every channel showed twice. The setup now rewrites the
  instance that already reads the list, and the service deletes extra ones
  at start (keeps the one that isn't 91, which holds what was watched) and
  restarts IPTV Simple when nothing plays.
- The web player registration for the new servers got one 401 error page
  and gave up, and the TV guide list then lacked LALIGA, DAZN LALIGA and
  Liga de Campeones until the next export 6 hours later. The registration
  is tried 3 times, and after a failure the service exports the list again
  every half hour, up to 6 times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:38:12 +01:00

649 lines
27 KiB
Python

# encoding: utf-8
#
# SPDX-License-Identifier: LGPL-2.1-or-later
#
# KodiMov+ additions to the Movistar+ add-on:
# - takes over the login of plugin.video.movistarplus on first run
# - logs in from the Movistar home network without a password
# - hides channels this add-on can't play
# - plays the channels that only Movistar's new servers serve (LALIGA,
# DAZN LALIGA, Liga de Campeones)
# - extra free channels for the TV guide
from __future__ import unicode_literals, absolute_import, division
import io
import json
import os
import shutil
import time
from .log import LOG
ORIGINAL_ADDON = 'plugin.video.movistarplus'
CLASSIC_ISSUER = 'http://www.movistarplus.es'
# Movistar marks the football and premium sports channels with QMWatermark.
# These play fine (tested 2026-11-07 on a Raspberry Pi 5, LibreELEC 12.2).
# LALIGA, DAZN LALIGA and Liga de Campeones are a separate case: see
# "Channels that only the new servers serve" below.
WATERMARKED_BUT_PLAYABLE = {
'CPDEP', 'ARTHUR', 'USOP2', 'USOP3', 'USOP11', 'MULTI8', # M+ Deportes 1-6
'M1SD', 'M2SD', 'DAZN3', 'DAZN4', # DAZN 1-4
'DAZBA', 'DAZB2', 'DAZB3', # DAZN Baloncesto 1-3
}
# Free channels that Movistar+ doesn't carry. 'chno' must not clash with a
# Movistar dial number.
EXTRA_CHANNELS = [
{'id': 'TVG2', 'name': 'TVG2', 'chno': 10, 'group': 'Galicia', 'epg_id': 'TVG2',
'logo': 'https://i.imgur.com/yFy6gxg.png',
'url': 'https://crtvg-tvg2.flumotion.cloud/playlist.m3u8'},
{'id': 'TeleVigo', 'name': 'TeleVigo', 'chno': 15, 'group': 'Galicia', 'epg_id': 'TELEVIGO',
'logo': 'https://i.imgur.com/4iVmlFa.png',
'url': 'https://stream-us-east-1.getpublica.com/playlist.m3u8?network_id=12687'},
]
# Programme listings for the extra channels (Movistar's guide covers the rest).
EXTRA_EPG_URL = 'https://raw.githubusercontent.com/davidmuma/EPG_dobleM/master/guiafanart_color.xml.gz'
AUTOLOGIN_URL = 'https://soter-pf.svp.video.telefonicaservices.com/service/login/autologin?deviceId={device}&deviceType=webplayer&_={ts}'
SOTER_INITDATA_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/login/initData?legacy=true'
SOTER_ACCOUNTINFO_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/login/webplayer/accountInfo?legacy=true'
SOTER_DEVICES_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/login/devices?deviceType=webplayer&accountId={account}&legacy=true'
SOTER_CDNTOKEN_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/tcdnidserver/{account}/devices/webplayer/cdn/token/refresh'
def is_playable(channel):
return not channel.get('watermark') or channel['id'] in WATERMARKED_BUT_PLAYABLE
def dial_number(channel):
try:
return int(channel.get('dial') or 0)
except (TypeError, ValueError):
return 0
def playable_channels(m, channels):
"""The channels this box can play. The ones served by the new servers
('new_api') play only where a session on those servers can be had."""
ready = None
res = []
for c in channels:
if c.get('new_api'):
if c.get('subscribed') and ready is None:
ready = new_api_ready(m)
if ready or not c.get('subscribed'): # not subscribed: greyed out like any other
res.append(c)
elif is_playable(c):
res.append(c)
return res
def migrate_from_original(config_directory):
"""Copies the login of plugin.video.movistarplus (tokens, registered device,
credentials) the first time KodiMov+ runs, so no new device is added to the
Movistar account and no password is needed."""
config_directory = config_directory.rstrip('/\\')
if os.path.exists(os.path.join(config_directory, 'account.txt')): return False
if any(d.startswith('account_') for d in os.listdir(config_directory)): return False
source = os.path.join(os.path.dirname(config_directory), ORIGINAL_ADDON)
if not os.path.isdir(source): return False
copied = False
for d in os.listdir(source):
src = os.path.join(source, d)
if d.startswith('account_') and os.path.isdir(src):
shutil.copytree(src, os.path.join(config_directory, d), ignore=shutil.ignore_patterns('cache'))
copied = True
if copied and os.path.exists(os.path.join(source, 'account.txt')):
shutil.copy(os.path.join(source, 'account.txt'), config_directory)
LOG('migrate_from_original: {}'.format('copied login from ' + ORIGINAL_ADDON if copied else 'nothing to copy'))
return copied
def _read_json(cache, filename):
content = cache.load_file(filename)
try:
return json.loads(content) if content else None
except ValueError:
return None
def find_account_number(m, setting=''):
data = _read_json(m.cache, 'account.json')
try:
return data['ofertas'][0]['accountNumber']
except (TypeError, KeyError, IndexError):
pass
data = _read_json(m.cache, 'tokens.json')
if isinstance(data, dict) and data.get('accountNumber'):
return data['accountNumber']
return (setting or '').strip()
def _autologin(m, device_id):
"""5-minute token for the new servers, bound to device_id. Only works from
the Movistar home network. Returns the token or raises."""
try:
from urllib.parse import urlsplit
except ImportError:
from urlparse import urlsplit
headers = m.net.headers.copy()
headers['Content-Type'] = 'application/json'
url = AUTOLOGIN_URL.format(device=device_id, ts=int(time.time() * 1000))
r = m.net.session.get(url, headers=headers, allow_redirects=False, timeout=(4, 10))
location = r.headers.get('Location', '')
if r.status_code in (301, 302, 303, 307, 308) and location:
if not urlsplit(location).netloc.endswith('.telefonicaservices.com'):
raise ValueError('unexpected redirect')
h2 = headers.copy()
h2['Origin'] = 'null'
r = m.net.session.get(location, headers=h2, allow_redirects=False, timeout=(4, 10))
token = r.json().get('access_token')
if not token: raise ValueError('autologin refused')
return token
def _soter(m, method, url, token, body=None, attempts=6):
"""Signed call to the new servers. They often answer with an HTML error
page (sometimes a 401) for no reason, so retry those."""
from .signature import get_auth
r = None
for attempt in range(attempts):
h = m.net.headers.copy()
h['Content-Type'] = 'application/json'
h['Accept'] = 'application/json, text/javascript, */*; q=0.01'
h['Authorization'] = get_auth(method, url, token)
try:
r = m.net.session.request(method.upper(), url, headers=h, timeout=(4, 15),
data=json.dumps(body) if body is not None else None)
text = r.content.decode('utf-8', errors='replace')
if not text.lstrip().startswith('<'):
return r.status_code, text
except Exception as e:
LOG('soter {} failed: {}'.format(url.split('?')[0], e))
LOG('soter {}: attempt {} got an error page ({})'.format(url.split('?')[0], attempt + 1, getattr(r, 'status_code', '?')))
if attempt + 1 < attempts: time.sleep(5)
return getattr(r, 'status_code', 0), ''
def network_login(m, account_number=''):
"""Password-less login from the Movistar home network.
1. autologin gives a 5-minute token for the new servers;
2. on a fresh box: accountInfo gives the account number and the device is
registered (one new device on the account, like a password login);
3. initData trades a device-bound token for a legacyAccessToken, which the
classic servers this add-on uses accept and renew for 10 days at a time.
Returns (success, reason)."""
import uuid
device_id = (m.cache.load_file('device_id.conf') or '').strip().strip('"')
try:
if not account_number or not device_id:
token = _autologin(m, device_id or uuid.uuid4().hex)
if not account_number:
status, text = _soter(m, 'get', SOTER_ACCOUNTINFO_URL, token)
try:
account_number = json.loads(text)['ofertas'][0]['accountNumber']
except (ValueError, KeyError, IndexError, TypeError):
return False, 'account not found ({})'.format(status)
if not device_id:
status, text = _soter(m, 'post', SOTER_DEVICES_URL.format(account=account_number), token, attempts=1)
new_id = text.strip().strip('"')
if status not in (200, 201) or len(new_id) != 32:
return False, 'device registration refused ({})'.format(status)
device_id = new_id
# Saved straight away, so a retry reuses it instead of adding another.
m.cache.save_file('device_id.conf', device_id)
LOG('network_login: registered device {}...'.format(device_id[:5]))
token = _autologin(m, device_id)
except Exception as e:
LOG('network_login: autologin failed: {}'.format(e))
return False, 'not on the Movistar network'
status, data = _init_data(m, token, account_number, device_id)
legacy = data.get('legacyAccessToken')
props = m.get_token_properties(legacy) if legacy else None
if not props or props.get('iss') != CLASSIC_ISSUER or props.get('exp', 0) < time.time():
return False, 'initData refused ({})'.format(status)
m.save_key_file({'access_token': legacy, 'token_type': 'bearer'})
m.cache.save_file('access_token.conf', legacy)
# The same answer carries the session for the channels of the new servers.
m.cache.save_file('web_device_id.conf', device_id)
_save_new_api_session(m, data, account_number)
LOG('network_login: OK')
return True, ''
def _init_data(m, token, account_number, device_id):
"""Starts a session on the new servers. Returns (status, answer); the
answer is {} when refused."""
body = {'accountNumber': account_number, 'sessionUserProfile': 0, 'isKidProfile': False,
'deviceId': device_id, 'streamMiscellanea': 'HTTPS', 'deviceManufacturerProduct': 'Firefox',
'streamDRM': 'Widevine', 'streamFormat': 'DASH'}
status, text = _soter(m, 'post', SOTER_INITDATA_URL, token, body)
try:
data = json.loads(text)
except ValueError:
data = None
return status, data if isinstance(data, dict) else {}
# --- Channels that only the new servers serve ------------------------------
#
# The streams of LALIGA, DAZN LALIGA and Liga de Campeones answer 403 to the
# CDN token of the classic servers, and the amazon.tv channel list this add-on
# reads doesn't even list them. They open with a CDN token from the new
# servers (tested 2026-11-13). Those servers start a session only for a device
# registered as a web player, and, without a password, only from the Movistar
# home network. Everything else (stream session, licence) stays on the classic
# servers, as for any other channel.
def web_only_channels(m, channels, profile, demarcation):
"""Channels of the web player's list that are missing from `channels` (the
amazon.tv list), marked with 'new_api'."""
cache_filename = 'channels_web_{}.json'.format(m.quality)
data = None
content = m.cache.load(cache_filename)
if content:
try:
data = json.loads(content)
except ValueError:
data = None
if not isinstance(data, list):
url = m.endpoints['canales'].format(deviceType='webplayer', profile=profile, mdrm='true', demarcation=demarcation)
if m.quality == 'UHD': url += '&filterQuality=UHD'
data = m.net.load_data(url)
if isinstance(data, list):
m.cache.save_file(cache_filename, json.dumps(data, ensure_ascii=False))
else: # download failed: an old list is better than losing the channels
data = _read_json(m.cache, cache_filename)
if not isinstance(data, list): return []
known = set(c.get('CodCadenaTv') for c in channels)
res = []
for c in data:
if c.get('CodCadenaTv') and c['CodCadenaTv'] not in known:
c = dict(c)
c['new_api'] = True
res.append(c)
return res
def web_only_epg(m, date=None, duration=2):
"""Programme guide of the web-only channels, grouped by channel like the
web player's guide: the amazon.tv guide doesn't have them. Without a date:
from today, kept for 6 hours like the main guide."""
from datetime import datetime
channel_ids = [c['id'] for c in m.get_channels() if c.get('new_api')]
if not channel_ids: return []
cache_filename = None
if not date:
cache_filename = 'epg_web_{}_{}.json'.format(m.quality, duration)
try:
data = json.loads(m.cache.load(cache_filename, 6*60) or 'null')
except ValueError:
data = None
if isinstance(data, list): return data
date = datetime.today().strftime('%Y-%m-%dT00:00:00')
url = m.endpoints['rejilla'].format(deviceType='webplayer', profile=m.account['platform'], UTCDATETIME=date,
DURATION=duration, CHANNELS=','.join(channel_ids).replace('+', '%2B'),
NETWORK='movistarplus', mdrm='true', demarcation=m.account['demarcation'])
if m.quality == 'UHD': url += '&filterQuality=UHD'
data = m.net.load_data(url)
if not isinstance(data, list): return []
groups = {}
for item in data:
for p in (item if isinstance(item, list) else [item]):
try:
groups.setdefault(p['Canal']['CodCadenaTv'], []).append(p)
except (KeyError, TypeError):
pass
res = [groups[id] for id in channel_ids if id in groups]
if cache_filename and res:
m.cache.save_file(cache_filename, json.dumps(res, ensure_ascii=False))
return res
def _web_device_id(m, account_number):
"""Id of this box as a web player. A box that logged in from the home
network already is one; a box that logged in with a password is registered
as another kind of device, so it gets a second, web player, registration
(one more device on the Movistar account, once)."""
import uuid
device_id = (m.cache.load_file('web_device_id.conf') or '').strip()
if device_id: return device_id
own = (m.account.get('device_id') or '').strip('"')
devices = m.get_devices()
if not devices: raise ValueError('device list unavailable')
for d in devices:
if d['id'] == own and (d.get('type_code') or '').startswith('WP'):
m.cache.save_file('web_device_id.conf', own)
return own
token = _autologin(m, uuid.uuid4().hex)
# Like the other calls, it at times gets a 401 error page for no reason
# (seen 2026-11-13: refused once, registered on the next try)
status, text = _soter(m, 'post', SOTER_DEVICES_URL.format(account=account_number), token, attempts=3)
device_id = text.strip().strip('"')
if status not in (200, 201) or len(device_id) != 32:
raise ValueError('device registration refused ({})'.format(status))
m.cache.save_file('web_device_id.conf', device_id)
LOG('new_api: registered web player device {}...'.format(device_id[:5]))
return device_id
def _save_new_api_session(m, data, account_number):
token = data.get('accessToken')
props = m.get_token_properties(token) if token else None
if not props or props.get('exp', 0) < time.time(): return None
session = {'access_token': token, 'account': account_number, 'expires': props['exp']}
m.cache.save_file('new_api.json', json.dumps(session))
return session
def new_api_session(m, force=False):
"""Session on the new servers: {'access_token', 'account', 'expires'}. It
lasts a day. Raises ValueError with the reason when it can't be had."""
session = _read_json(m.cache, 'new_api.json')
if not force and isinstance(session, dict) and session.get('expires', 0) > time.time() + 600:
return session
account_number = m.account.get('id') or find_account_number(m)
if not account_number: raise ValueError('not logged in')
for attempt in (1, 2):
device_id = _web_device_id(m, account_number)
try:
token = _autologin(m, device_id)
except Exception as e:
LOG('new_api: autologin failed: {}'.format(e))
raise ValueError('not on the Movistar network')
status, data = _init_data(m, token, account_number, device_id)
session = _save_new_api_session(m, data, account_number)
if session:
LOG('new_api: session OK')
return session
# Refused for good when the device was removed from the account: start over.
devices = m.get_devices()
if attempt == 1 and devices and device_id not in [d['id'] for d in devices]:
LOG('new_api: web player device no longer on the account')
m.cache.remove_file('web_device_id.conf')
continue
break
raise ValueError('initData refused ({})'.format(status))
def new_api_ready(m):
"""True on a box that can play the channels of the new servers. Once it
could, it stays true: a passing failure must not empty the TV guide. While
it can't, it tries again at most every 10 minutes."""
if m.cache.load_file('new_api.json'): return True
stamp = m.cache.config_directory + 'new_api.stamp'
if os.path.exists(stamp) and time.time() - os.path.getmtime(stamp) < 600:
return False
m.cache.save_file('new_api.stamp', str(int(time.time())))
try:
new_api_session(m)
return True
except Exception as e:
LOG('new_api: not available: {}'.format(e))
return False
def new_api_failed(profile_dir):
"""True if the last try to reach the new servers failed and none ever
worked: the TV guide list was written without LALIGA, DAZN LALIGA and
Liga de Campeones, so the service exports it again sooner."""
import glob
for stamp in glob.glob(os.path.join(profile_dir, 'account_*', 'new_api.stamp')):
if not os.path.exists(os.path.join(os.path.dirname(stamp), 'new_api.json')): return True
return False
def _token_life(m, token):
"""Seconds until the token expires (negative: expired or unreadable)."""
props = m.get_token_properties(token) if token else None
return (props or {}).get('exp', 0) - time.time()
def new_api_cdn_token(m, min_life=600, attempts=2):
"""CDN token from the new servers, for the manifest and the video of the
'new_api' channels. It lasts 12 hours and, like the web player does, it is
used until shortly before it expires: the server that gives it out sits
behind a filter that at times refuses everything for minutes. Raises
ValueError with the reason."""
cached = (m.cache.load_file('cdn_new.conf') or '').strip()
if _token_life(m, cached) > min_life: return cached
status, text = 0, ''
for force in (False, True):
session = new_api_session(m, force)
status, text = _soter(m, 'post', SOTER_CDNTOKEN_URL.format(account=session['account']),
session['access_token'], {}, attempts=attempts)
try:
token = json.loads(text).get('access_token')
except (ValueError, AttributeError):
token = None
if token:
m.cache.save_file('cdn_new.conf', token)
return token
if not text: break # error pages: a new session wouldn't get through either
if _token_life(m, cached) > 60: return cached # still good for now
raise ValueError('Movistar servers busy, try again in a minute' if not text
else 'CDN token refused ({})'.format(status))
def new_api_keep_warm(m):
"""Run now and then by the service: renews the CDN token hours before it
expires, so that opening a channel never has to wait for the new servers."""
if not m.cache.load_file('new_api.json'): return # this box doesn't use them
try:
new_api_cdn_token(m, min_life=6 * 3600, attempts=1)
except Exception as e:
LOG('new_api: keep warm failed: {}'.format(e))
def extra_channels_m3u8():
"""Returns [(channel number, m3u8 entry)]."""
return [(c['chno'], '#EXTINF:-1 tvg-name="{name}" tvg-id="{id}" tvg-logo="{logo}" tvg-chno="{chno}" group-title="{group}",{name}\n{url}\n\n'.format(**c))
for c in EXTRA_CHANNELS]
def write_if_changed(filename, content):
try:
with io.open(filename, 'r', encoding='utf-8', newline='') as f:
if f.read() == content:
os.utime(filename, None) # the service reads the time as "last refreshed"
return False
except (IOError, OSError):
pass
tmp = filename + '.tmp'
with io.open(tmp, 'w', encoding='utf-8', newline='') as f:
f.write(content)
os.replace(tmp, filename) if hasattr(os, 'replace') else shutil.move(tmp, filename)
return True
def set_iptv_refresh(pvr_dir, addon_id, minutes=60):
"""Makes the IPTV Simple instances that read this add-on's files reload the
channel list and programme guide every few minutes. Each reload also makes
Kodi fetch the guide again. TV guide setups before kodimov.7 reloaded only
once a day, at 4:00. Returns True if a file changed (IPTV Simple must be
restarted to use it)."""
import glob
import re
changed = False
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
with io.open(filename, 'r', encoding='utf-8') as f:
content = f.read()
if 'plugin.video.{}/channels.m3u8'.format(addon_id) not in content: continue
new = content
for setting, value in (('m3uRefreshMode', '1'), ('m3uRefreshIntervalMins', str(minutes))):
new = re.sub(r'<setting id="{}"[^>]*?(/>|>[^<]*</setting>)'.format(setting),
'<setting id="{}">{}</setting>'.format(setting, value), new)
if new != content:
write_if_changed(filename, new)
LOG('set_iptv_refresh: {} minutes in {}'.format(minutes, filename))
changed = True
return changed
def reload_iptv_guide(pvr_dir, addon_id):
"""Gets a new programme guide into Kodi now instead of at the next hourly
reload. Restarting IPTV Simple isn't enough: Kodi keeps its own copy of the
guide and fetches it again only when IPTV Simple reloads, which happens on
its timer. So restart it with a 1-minute timer, wait for that reload, then
restart it with the hourly timer. Restarting stops live TV, so this does
nothing if something is playing."""
import xbmc
monitor, player = xbmc.Monitor(), xbmc.Player()
if player.isPlaying(): return
if not set_iptv_refresh(pvr_dir, addon_id, 1): return
try:
restart_iptv_simple()
monitor.waitForAbort(90)
finally:
set_iptv_refresh(pvr_dir, addon_id, 60)
while player.isPlaying(): # until then it keeps reloading every minute
if monitor.waitForAbort(10): return
if not monitor.abortRequested():
restart_iptv_simple()
# IPTV Simple is shared with other add-ons (Radios de Vigo keeps its own
# instance for the Radio 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 others wait.
IPTV_RESTART_PROPERTY = 'pvr.iptvsimple.restarting'
def restart_iptv_simple():
"""Restarts IPTV Simple so it loads its instance settings again."""
import xbmc
import xbmcgui
window = xbmcgui.Window(10000)
for _ in range(40): # another add-on 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):
xbmc.executeJSONRPC(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Addons.SetAddonEnabled',
'params': {'addonid': 'pvr.iptvsimple', 'enabled': enabled}}))
xbmc.sleep(3000)
finally:
window.clearProperty(IPTV_RESTART_PROPERTY)
SETUP_INSTANCE_FILE = 'instance-settings-91.xml' # written by "Create IPTV settings"
def iptv_instance_files(pvr_dir, addon_id):
"""The IPTV Simple instance files that read this add-on's channel list,
the one to keep first: a box set up before (with the original add-on, or
by hand) has its instance besides 91, and that one holds what was watched;
91 is the one the TV guide setup writes."""
import glob
res = []
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
try:
with io.open(filename, 'r', encoding='utf-8') as f:
if 'plugin.video.{}/channels.m3u8'.format(addon_id) in f.read(): res.append(filename)
except (IOError, OSError):
pass
def number(filename):
try:
return int(os.path.basename(filename)[len('instance-settings-'):-len('.xml')])
except ValueError:
return 0
res.sort(key=lambda f: (os.path.basename(f) == SETUP_INSTANCE_FILE, number(f)))
return res
def has_iptv_instance(pvr_dir, addon_id):
"""True if an IPTV Simple instance reads this add-on's channel list."""
return bool(iptv_instance_files(pvr_dir, addon_id))
def remove_duplicate_instances(pvr_dir, addon_id):
"""Two instances reading the same channel list put every channel twice in
the TV guide. That happened when the TV guide setup (instance 91) was run
on a box already set up with another instance. Keeps one and deletes the
others. Returns True if a file was deleted (IPTV Simple must be restarted
to drop it)."""
removed = False
for filename in iptv_instance_files(pvr_dir, addon_id)[1:]:
try:
os.remove(filename)
LOG('remove_duplicate_instances: removed {}'.format(filename))
removed = True
except OSError as e:
LOG('remove_duplicate_instances: {}: {}'.format(filename, e))
return removed
def ensure_channel_numbers(marker):
"""Makes Kodi show the channel numbers of the list (Movistar's dial
numbers) 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. Radios de Vigo adds an instance of IPTV Simple and
some boxes carry a leftover empty one, so the setting can stop applying
after it was turned on. This is called 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 Radios de Vigo needs.
Returns True when Kodi is using the numbers of the list."""
import xbmc
def rpc(method, **params):
try:
return json.loads(xbmc.executeJSONRPC(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params})))
except ValueError:
return {}
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_if_changed(marker, '1')
LOG('ensure_channel_numbers: on')
return value is True
def extra_channels_xmltv(session):
"""Returns the <channel> and <programme> elements for the extra channels,
taken from a public XMLTV guide and renamed to their tvg-id."""
import gzip
import xml.etree.ElementTree as ET
wanted = dict((c['epg_id'], c) for c in EXTRA_CHANNELS if c.get('epg_id'))
out = []
for c in EXTRA_CHANNELS:
out.append('<channel id="{id}">\n <display-name>{name}</display-name>\n <icon src="{logo}"/>\n</channel>\n'.format(**c))
try:
r = session.get(EXTRA_EPG_URL, timeout=(10, 120))
r.raise_for_status()
found = 0
for _, el in ET.iterparse(gzip.GzipFile(fileobj=io.BytesIO(r.content))):
if el.tag == 'programme':
c = wanted.get(el.get('channel'))
if c:
el.set('channel', c['id'])
for icon in el.findall('icon'): el.remove(icon) # large fan art
out.append(ET.tostring(el, encoding='unicode') + '\n')
found += 1
el.clear()
elif el.tag == 'channel':
el.clear()
LOG('extra_channels_xmltv: {} programmes'.format(found))
except Exception as e:
LOG('extra_channels_xmltv: failed: {}'.format(e))
return out