1.0.0+kodimov.10: LALIGA, DAZN LALIGA and Liga de Campeones play again
- 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>
This commit is contained in:
1 parent
e16dd66f63
commit
e72e3d4902
10 files changed
+337
-25
No files matched your search
+247
-11
@@ -6,6 +6,8 @@
|
||||
# - 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
|
||||
@@ -22,8 +24,9 @@ ORIGINAL_ADDON = 'plugin.video.movistarplus'
|
||||
CLASSIC_ISSUER = 'http://www.movistarplus.es'
|
||||
|
||||
# Movistar marks the football and premium sports channels with QMWatermark.
|
||||
# The LALIGA and Liga de Campeones ones refuse their manifest to this add-on;
|
||||
# these play fine (tested 2026-11-07 on a Raspberry Pi 5, LibreELEC 12.2).
|
||||
# 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
|
||||
@@ -48,12 +51,36 @@ AUTOLOGIN_URL = 'https://soter-pf.svp.video.telefonicaservices.com/service/login
|
||||
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
|
||||
@@ -137,7 +164,7 @@ def _soter(m, method, url, token, body=None, attempts=6):
|
||||
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', '?')))
|
||||
time.sleep(5)
|
||||
if attempt + 1 < attempts: time.sleep(5)
|
||||
return getattr(r, 'status_code', 0), ''
|
||||
|
||||
|
||||
@@ -174,23 +201,232 @@ def network_login(m, account_number=''):
|
||||
LOG('network_login: autologin failed: {}'.format(e))
|
||||
return False, 'not on the Movistar network'
|
||||
|
||||
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:
|
||||
legacy = json.loads(text).get('legacyAccessToken')
|
||||
except (ValueError, AttributeError):
|
||||
legacy = None
|
||||
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))
|
||||
|
||||
Reference in new issue
Block a user