- After an add-on update the new service found port 57031 still held by the old proxy for a moment and moved to a random port; the stream playing kept asking 57031 for its manifest and died a minute later (.49, 2026-11-13). The proxy now waits up to 10 s for the port, and SO_REUSEADDR / allow_reuse_address are set before binding (upstream set them after, so they did nothing). - IPTV Simple is restarted only when nothing plays and no key was pressed for 30 s: on .49 a channel opened during a restart failed with an error. - After start or update, changed instance files (duplicate removed, refresh timer) are picked up by the guide reload instead of a restart of their own; the service restarts IPTV Simple itself only if that reload didn't happen within 10 minutes. 3 restarts after kodimov.12 -> 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
676 lines
28 KiB
Python
676 lines
28 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 = xbmc.Monitor()
|
|
if not wait_until_idle(timeout=600): 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)
|
|
if wait_until_idle(): # until then it keeps reloading every minute
|
|
restart_iptv_simple()
|
|
|
|
|
|
def is_idle(seconds=30):
|
|
"""Nothing playing and nobody pressed a key for a while. Restarting IPTV
|
|
Simple stops live TV, and a channel opened while it restarts fails."""
|
|
import xbmc
|
|
return not xbmc.Player().isPlaying() and xbmc.getGlobalIdleTime() >= seconds
|
|
|
|
|
|
def wait_until_idle(timeout=None):
|
|
"""Waits until is_idle(). False if Kodi is closing or the timeout ran out."""
|
|
import xbmc
|
|
monitor = xbmc.Monitor()
|
|
end = time.time() + timeout if timeout else None
|
|
while not is_idle():
|
|
if monitor.waitForAbort(5) or (end and time.time() > end): return False
|
|
return not monitor.abortRequested()
|
|
|
|
|
|
# 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'
|
|
# When this add-on last restarted it, so the service can skip a restart that
|
|
# one from the plugin (the guide reload) already did
|
|
LAST_RESTART_PROPERTY = 'kodimovplus.iptvsimple.restarted'
|
|
|
|
|
|
def last_iptv_restart():
|
|
import xbmcgui
|
|
try:
|
|
return float(xbmcgui.Window(10000).getProperty(LAST_RESTART_PROPERTY) or 0)
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
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()))
|
|
window.setProperty(LAST_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
|