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:
XoséandClaude Fable 5.2 committed 2026-11-13 13:17:20 +01:00
1 parent e16dd66f63
commit e72e3d4902
10 files changed
+337 -25

No files matched your search

@@ -643,7 +643,7 @@ msgid "Log in automatically from the Movistar home network"
msgstr ""
msgctxt "#30506"
msgid "Hide channels this add-on can't play (LALIGA, Liga de Campeones)"
msgid "Hide channels this add-on can't play"
msgstr ""
msgctxt "#30507"
@@ -657,3 +657,11 @@ msgstr ""
msgctxt "#30509"
msgid "Log in first: there is no channel list yet"
msgstr ""
msgctxt "#30510"
msgid "Can't play this channel: {}"
msgstr ""
msgctxt "#30511"
msgid "Always play at the best quality, up to 1080p (on automatic, Kodi stays at 576p)"
msgstr ""
@@ -606,8 +606,8 @@ msgid "Log in automatically from the Movistar home network"
msgstr "Iniciar sesión automáticamente desde la red de Movistar"
msgctxt "#30506"
msgid "Hide channels this add-on can't play (LALIGA, Liga de Campeones)"
msgstr "Ocultar los canales que no se pueden reproducir (LALIGA, Liga de Campeones)"
msgid "Hide channels this add-on can't play"
msgstr "Ocultar los canales que no se pueden reproducir"
msgctxt "#30507"
msgid "Add free Galician channels (TVG2, TeleVigo) to the TV guide"
@@ -621,6 +621,14 @@ msgctxt "#30509"
msgid "Log in first: there is no channel list yet"
msgstr "Inicia sesión primero: todavía no hay lista de canales"
msgctxt "#30510"
msgid "Can't play this channel: {}"
msgstr "No se puede reproducir este canal: {}"
msgctxt "#30511"
msgid "Always play at the best quality, up to 1080p (on automatic, Kodi stays at 576p)"
msgstr "Reproducir siempre con la mejor calidad, hasta 1080p (en automático, Kodi se queda en 576p)"
#~ msgctxt "#30040"
#~ msgid "Use only for these languages (e.g.: es en pt):"
#~ msgstr "Usar solo para estos idiomas (ej: es en pt):"
+247 -11
View File
@@ -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))
+19 -3
View File
@@ -40,6 +40,7 @@ class Movistar(object):
add_extra_info = True
hide_unplayable = True
add_extra_channels = True
add_web_channels = True
#dplayer = 'webplayer'
#device_code = 'WP_OTT'
#manufacturer = 'Firefox'
@@ -548,6 +549,10 @@ class Movistar(object):
groups.setdefault(p['Canal']['CodCadenaTv'], []).append(p)
data = list(groups.values())
# The channels of the new servers aren't in the amazon.tv guide
if self.add_web_channels and not channels:
data = data + kodimov.web_only_epg(self, date, duration)
for ch in data:
if not ch: continue
id = ch[0]['Canal']['CodCadenaTv']
@@ -681,6 +686,10 @@ class Movistar(object):
if not 'error' in data:
self.cache.save_file(cache_filename, json.dumps(data, ensure_ascii=False))
# LALIGA, DAZN LALIGA and Liga de Campeones are only in the web player's list
if self.add_web_channels and isinstance(data, list):
data = data + kodimov.web_only_channels(self, data, profile, demarcation)
res = []
if 'error' in data: return res
@@ -709,6 +718,7 @@ class Movistar(object):
t['info']['playcount'] = 1 # Set as watched
t['uid'] = c.get('Uid')
t['watermark'] = bool(c.get('QMWatermark'))
t['new_api'] = bool(c.get('new_api'))
if False and 'links' in c:
for l in c['links']:
if l['rel'] == 'watermarks':
@@ -727,8 +737,11 @@ class Movistar(object):
res.append(t)
if any(t['new_api'] for t in res):
# Back in dial order (a stable sort: the rest of the list doesn't move)
res.sort(key=kodimov.dial_number)
if self.hide_unplayable:
res = [t for t in res if kodimov.is_playable(t)]
res = kodimov.playable_channels(self, res)
return res
def get_channels_with_epg(self):
@@ -1386,7 +1399,8 @@ class Movistar(object):
shutil.copyfile(self.cache.config_directory + 'credentials.json', filename)
def delete_session_files(self):
for f in ['access_token.conf', 'account.json', 'device_id.conf', 'devices.json', 'profile_id.conf', 'tokens.json', 'channels2.json', 'channels_UHD.json', 'channels_HD.json', 'epg2.json', 'epg_UHD.json', 'epg_HD.json', 'cdn.conf']:
for f in ['access_token.conf', 'account.json', 'device_id.conf', 'devices.json', 'profile_id.conf', 'tokens.json', 'channels2.json', 'channels_UHD.json', 'channels_HD.json', 'epg2.json', 'epg_UHD.json', 'epg_HD.json', 'cdn.conf',
'new_api.json', 'new_api.stamp', 'cdn_new.conf']:
self.cache.remove_file(f)
def get_profile_image_url(self, img_id):
@@ -1420,6 +1434,7 @@ class Movistar(object):
t['logo'] = c['art']['icon']
t['preset'] = c['dial']
args = urlencode({'action': 'play', 'stype': 'tv', 'id': c['id'], 'url': c['url'], 'session_request': c['session_request']})
if c.get('new_api'): args += '&new_api=1'
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args
if 'cas_id' in c: t['cas_id'] = c['cas_id']
res.append(t)
@@ -1454,6 +1469,7 @@ class Movistar(object):
for channel in channels:
id = channel['id']
if not id in epg: continue
new_api = '&new_api=1' if channel.get('new_api') else ''
res[id] = []
for e in epg[id]:
t = {}
@@ -1470,7 +1486,7 @@ class Movistar(object):
url = 'https://grmovistar.imagenio.telefonica.net/asfe/rest/tvMediaURLs?tvProgram.id='+ program_id +'&svc=startover'
session_request = '{"contentID":' + program_id +',"streamType":"CUTV"}'
args = urlencode({'action': 'play', 'stype': 'u7d', 'id': program_id, 'url': url, 'session_request': session_request})
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args + new_api
if False and self.add_extra_info and id in ['HOLLYW', 'TCM', 'AMC', 'MV3', 'MV2', 'CPSER', 'FOXGE', 'TNT']:
i = {'id': e['id'], 'show_id': e['show_id']}
+26 -1
View File
@@ -123,7 +123,19 @@ def play(params):
#headers += '&Host=wv-ottlic-f3.imagenio.telefonica.net'
manifest_headers = 'User-Agent=' + useragent
if True: #stype in ['tv', 'u7d', 'rec']:
if params.get('new_api') == '1':
# LALIGA, DAZN LALIGA, Liga de Campeones: their streams only open with a
# CDN token from the new servers. The player keeps the token it starts
# with, so it must have hours left.
from . import kodimov
try:
cdn_token = kodimov.new_api_cdn_token(m, min_life=3 * 3600)
except Exception as e:
LOG('new_api_cdn_token failed: {}'.format(e))
show_notification(addon.getLocalizedString(30510).format(e))
return
manifest_headers += '&x-tcdn-token=' + cdn_token
elif True: #stype in ['tv', 'u7d', 'rec']:
cdn_token = m.cache.load('cdn.conf', 60)
if not cdn_token:
cdn_token = m.get_cdntoken()
@@ -177,6 +189,14 @@ def play(params):
play_item.setProperty('inputstream.adaptive.manifest_type', 'mpd')
#play_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
if addon.getSettingBool('best_quality'):
# Left to itself, InputStream Adaptive starts these streams at 576p and
# never gets to the 720p/1080p versions (they use a different key)
best = '4K' if addon.getSettingBool('uhd') else '1080p'
play_item.setProperty('inputstream.adaptive.stream_selection_type', 'fixed-res')
play_item.setProperty('inputstream.adaptive.chooser_resolution_max', best)
play_item.setProperty('inputstream.adaptive.chooser_resolution_secure_max', best)
play_item.setProperty('inputstream.adaptive.stream_headers', manifest_headers)
play_item.setProperty('inputstream.adaptive.manifest_headers', manifest_headers)
@@ -397,6 +417,7 @@ def add_videos(category, ctype, videos, ref=None, url_next=None, url_prev=None,
list_item.addContextMenuItems(actions)
url = get_url(action='play', id=t['id'], url=t['url'], session_request=t['session_request'], stype=t['stream_type'])
if t.get('new_api'): url += '&new_api=1'
if 'show_id' in t: url += '&show_id={}'.format(t['show_id'])
if 'uid' in t: url += '&uid={}'.format(t['uid'])
xbmcplugin.addDirectoryItem(_handle, url, list_item, False)
@@ -862,6 +883,10 @@ def router(paramstring):
network_login()
elif params['action'] == 'export_channels_now':
export_channels_now()
elif params['action'] == 'new_api_keep_warm':
if m.logged:
from . import kodimov
kodimov.new_api_keep_warm(m)
elif params['action'] == 'export_key':
export_key()
elif params['action'] == 'export_credentials':
+7 -2
View File
@@ -241,12 +241,17 @@ class RequestHandler(BaseHTTPRequestHandler):
LOG('license response: {}'.format(license_data))
d = try_load_json(license_data)
if d and 'errorCode' in d:
from .gui import show_notification
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
# KodiMov+: the error is shown only when the retry below
# doesn't get a licence. The first request for a LALIGA /
# Liga de Campeones channel is refused with 4027 every time,
# and passes once the device is registered again (a pause
# and a plain retry don't help; tested 2026-11-13).
if d['errorCode'] == 4027:
if not reregister_needed and addon.getSettingBool('reregister'):
reregister_needed = True
continue
from .gui import show_notification
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
else:
LOG('license response: {}'.format(encode_base64(license_data)))
break
+1
View File
@@ -10,6 +10,7 @@
<setting label="30020" type="bool" id="channels_with_epg" default="false"/>
<setting label="30021" type="bool" id="add_extra_info" default="false"/>
<setting label="30033" type="bool" id="uhd" default="false"/>
<setting label="30511" type="bool" id="best_quality" default="false"/>
<setting label="30042" type="number" id="expdays" default="30"/>
<setting id="use_ttml2ssa" type="bool" label="$ADDON[script.module.ttml2ssa 32202]" default="false"/>
<setting id="ssa_settings" type="action" label="$ADDON[script.module.ttml2ssa 32201]" option="close" action="Addon.OpenSettings(script.module.ttml2ssa)" visible="eq(-1,true)" subsetting="true"/>