- These 25 channels are missing from the amazon.tv channel list and their streams answer 403 to the CDN token of the classic servers. They are now taken from the web player's channel list and guide and opened with a CDN token from Movistar's new servers (live and catch-up). Stream session and licence stay on the classic servers. The other channels are untouched. - The new servers need a device registered as a web player and the Movistar home network: a box registered otherwise gets one extra registration; away from the home network the channels are not listed. - The CDN token is used for its 12 hours and renewed in the background: the server that gives it out at times refuses everything for minutes. - New setting, off by default: always play at the best quality, up to 1080p (InputStream Adaptive stays at 576p on these streams when left on automatic). - Error 4027 is shown only when the retry after registering the device again fails too; the first licence request for these channels always gets it. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
534 lines
22 KiB
Python
534 lines
22 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)
|
|
status, text = _soter(m, 'post', SOTER_DEVICES_URL.format(account=account_number), token, attempts=1)
|
|
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 _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()
|
|
|
|
|
|
def restart_iptv_simple():
|
|
"""Restarts IPTV Simple so it loads its instance settings again."""
|
|
import xbmc
|
|
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)
|
|
|
|
|
|
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
|