1.0.0+kodimov.4: password-less first login from the Movistar network

On a new box the home-network login now also finds the account number
(accountInfo) and registers the box as a device, so no email, password
or account number is needed. The new device id is saved straight away,
so a retry reuses it instead of adding another one. Error pages from the
new servers (including HTML 401s) are retried.

Tested live on 2026-11-07 with a fresh profile; the test devices were
deleted afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-11-07 12:13:31 +01:00
1 parent c4a0a7e97d
commit c9f062206c
6 files changed
+72 -36

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ Fork of [Paco8's Movistar+ add-on](https://github.com/Paco8/plugin.video.movista
`https://git.anosanube.ovh/xocko/kodi-repo/raw/branch/main/repository.xocko.zip` `https://git.anosanube.ovh/xocko/kodi-repo/raw/branch/main/repository.xocko.zip`
What it adds: What it adds:
- **Login from the Movistar home network**, no password. The home-network login gives a token for Movistar's new servers, which is traded once for a classic token; the classic servers renew it every 10 days. It needs the account number (taken from an existing login, or set in the settings) and an already registered device. - **Login from the Movistar home network**, no email, password or account number. On first open, on a box connected to the Movistar home network, KodiMov+ gets a home-network token, finds the account, registers the box as one device (like a password login does) and trades the token for a classic login, which the classic servers renew every 10 days. Outside the Movistar network, log in once with the email and password (Accounts > Cuenta 1 > Login with username).
- **Takes over the login of plugin.video.movistarplus** on first run (same registered device, so no new device is added to the account). - **Takes over the login of plugin.video.movistarplus** on first run (same registered device, so no new device is added to the account).
- **Hides channels it can't play**: Movistar marks football and premium sports with `QMWatermark`; LALIGA and Liga de Campeones refuse their manifest, while M+ Deportes, DAZN and DAZN Baloncesto play and are kept (`resources/lib/kodimov.py`). - **Hides channels it can't play**: Movistar marks football and premium sports with `QMWatermark`; LALIGA and Liga de Campeones refuse their manifest, while M+ Deportes, DAZN and DAZN Baloncesto play and are kept (`resources/lib/kodimov.py`).
- **Extra free channels** in the TV guide list: TVG2 (10) and TeleVigo (15). - **Extra free channels** in the TV guide list: TVG2 (10) and TeleVigo (15).
+3 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.kodimovplus" <addon id="plugin.video.kodimovplus"
name="KodiMov+" name="KodiMov+"
version="1.0.0+kodimov.3" version="1.0.0+kodimov.4"
provider-name="Paco8, xocko"> provider-name="Paco8, xocko">
<requires> <requires>
<!--- <import addon="xbmc.python" version="2.25.0"/> --> <!--- <import addon="xbmc.python" version="2.25.0"/> -->
@@ -21,7 +21,8 @@
<summary lang="es_ES">Movistar+ para Kodi, con inicio de sesión automático desde la red de Movistar</summary> <summary lang="es_ES">Movistar+ para Kodi, con inicio de sesión automático desde la red de Movistar</summary>
<description lang="en_GB">Fork of Paco8's Movistar+ add-on (plugin.video.movistarplus). Logs in by itself from the Movistar home network, keeps the channel list and numbers up to date, hides the channels that need stronger DRM than Kodi has, adds free Galician channels (TVG2, TeleVigo) and writes a programme guide for every channel. Requires a Movistar+ subscription.</description> <description lang="en_GB">Fork of Paco8's Movistar+ add-on (plugin.video.movistarplus). Logs in by itself from the Movistar home network, keeps the channel list and numbers up to date, hides the channels that need stronger DRM than Kodi has, adds free Galician channels (TVG2, TeleVigo) and writes a programme guide for every channel. Requires a Movistar+ subscription.</description>
<description lang="es_ES">Fork del addon Movistar+ de Paco8 (plugin.video.movistarplus). Inicia sesión solo desde la red de Movistar, mantiene la lista y los números de los canales al día, oculta los canales que necesitan una protección que Kodi no tiene añade canales gallegos gratuitos (TVG2, TeleVigo) y genera la guía de programación de todos los canales. Se necesita estar abonado a Movistar+.</description> <description lang="es_ES">Fork del addon Movistar+ de Paco8 (plugin.video.movistarplus). Inicia sesión solo desde la red de Movistar, mantiene la lista y los números de los canales al día, oculta los canales que necesitan una protección que Kodi no tiene añade canales gallegos gratuitos (TVG2, TeleVigo) y genera la guía de programación de todos los canales. Se necesita estar abonado a Movistar+.</description>
<news>1.0.0+kodimov.3: one-tap TV setup: "Create settings for IPTV Simple" also turns on Movistar channel numbers, daily list reload, and restarts IPTV Simple; fixes invalid XML in that file. <news>1.0.0+kodimov.4: first login on a new box needs no password from the Movistar home network: the account is found and the box registered automatically.
1.0.0+kodimov.3: one-tap TV setup: "Create settings for IPTV Simple" also turns on Movistar channel numbers, daily list reload, and restarts IPTV Simple; fixes invalid XML in that file.
1.0.0+kodimov.2: programme guide for every channel (Movistar + TVG2/TeleVigo), TeleVigo on 15, silent background refresh. 1.0.0+kodimov.2: programme guide for every channel (Movistar + TVG2/TeleVigo), TeleVigo on 15, silent background refresh.
1.0.0+kodimov.1: first KodiMov+ release, based on Movistar+ 1.0.0.</news> 1.0.0+kodimov.1: first KodiMov+ release, based on Movistar+ 1.0.0.</news>
<disclaimer lang="en_GB">This addon is not officially commissioned/supported by Movistar+. All product names, logos, and trademarks mentioned in this project are property of their respective owners.</disclaimer> <disclaimer lang="en_GB">This addon is not officially commissioned/supported by Movistar+. All product names, logos, and trademarks mentioned in this project are property of their respective owners.</disclaimer>
Binary file not shown.
Binary file not shown.
+67 -29
View File
@@ -46,6 +46,8 @@ EXTRA_EPG_URL = 'https://raw.githubusercontent.com/davidmuma/EPG_dobleM/master/g
AUTOLOGIN_URL = 'https://soter-pf.svp.video.telefonicaservices.com/service/login/autologin?deviceId={device}&deviceType=webplayer&_={ts}' 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_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'
def is_playable(channel): def is_playable(channel):
@@ -93,60 +95,96 @@ def find_account_number(m, setting=''):
return (setting or '').strip() return (setting or '').strip()
def network_login(m, account_number): def _autologin(m, device_id):
"""Password-less login from the Movistar home network. """5-minute token for the new servers, bound to device_id. Only works from
1. autologin (only reachable inside Movistar's network) gives a 5-minute the Movistar home network. Returns the token or raises."""
token for the new servers;
2. their initData trades it for a legacyAccessToken, which the classic
servers this add-on uses accept (and renew for 10 days at a time).
Returns (success, reason)."""
try: try:
from urllib.parse import urlsplit from urllib.parse import urlsplit
except ImportError: except ImportError:
from urlparse import urlsplit from urlparse import urlsplit
from .signature import get_auth
device_id = (m.cache.load_file('device_id.conf') or '').strip().strip('"')
if not account_number: return False, 'no account number'
if not device_id: return False, 'no registered device'
headers = m.net.headers.copy() headers = m.net.headers.copy()
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
url = AUTOLOGIN_URL.format(device=device_id, ts=int(time.time() * 1000)) url = AUTOLOGIN_URL.format(device=device_id, ts=int(time.time() * 1000))
try:
r = m.net.session.get(url, headers=headers, allow_redirects=False, timeout=(4, 10)) r = m.net.session.get(url, headers=headers, allow_redirects=False, timeout=(4, 10))
location = r.headers.get('Location', '') location = r.headers.get('Location', '')
if r.status_code in (301, 302, 303, 307, 308) and location: if r.status_code in (301, 302, 303, 307, 308) and location:
if not urlsplit(location).netloc.endswith('.telefonicaservices.com'): if not urlsplit(location).netloc.endswith('.telefonicaservices.com'):
return False, 'unexpected redirect' raise ValueError('unexpected redirect')
h2 = headers.copy() h2 = headers.copy()
h2['Origin'] = 'null' h2['Origin'] = 'null'
r = m.net.session.get(location, headers=h2, allow_redirects=False, timeout=(4, 10)) r = m.net.session.get(location, headers=h2, allow_redirects=False, timeout=(4, 10))
token = r.json().get('access_token') 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', '?')))
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: except Exception as e:
LOG('network_login: autologin failed: {}'.format(e)) LOG('network_login: autologin failed: {}'.format(e))
return False, 'not on the Movistar network' return False, 'not on the Movistar network'
if not token: return False, 'autologin refused'
body = {'accountNumber': account_number, 'sessionUserProfile': 0, 'isKidProfile': False, body = {'accountNumber': account_number, 'sessionUserProfile': 0, 'isKidProfile': False,
'deviceId': device_id, 'streamMiscellanea': 'HTTPS', 'deviceManufacturerProduct': 'Firefox', 'deviceId': device_id, 'streamMiscellanea': 'HTTPS', 'deviceManufacturerProduct': 'Firefox',
'streamDRM': 'Widevine', 'streamFormat': 'DASH'} 'streamDRM': 'Widevine', 'streamFormat': 'DASH'}
data = None status, text = _soter(m, 'post', SOTER_INITDATA_URL, token, body)
for attempt in range(4):
h = headers.copy()
h['Authorization'] = get_auth('post', SOTER_INITDATA_URL, token)
try: try:
r = m.net.session.post(SOTER_INITDATA_URL, data=json.dumps(body), headers=h, timeout=(4, 15)) legacy = json.loads(text).get('legacyAccessToken')
data = r.json() except (ValueError, AttributeError):
break legacy = None
except Exception:
# The new servers sometimes answer with an HTML error page; retry.
LOG('network_login: initData attempt {} failed ({})'.format(attempt + 1, getattr(r, 'status_code', '?')))
time.sleep(3)
legacy = data.get('legacyAccessToken') if isinstance(data, dict) else None
props = m.get_token_properties(legacy) if legacy else None 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(): if not props or props.get('iss') != CLASSIC_ISSUER or props.get('exp', 0) < time.time():
return False, 'initData refused' return False, 'initData refused ({})'.format(status)
m.save_key_file({'access_token': legacy, 'token_type': 'bearer'}) m.save_key_file({'access_token': legacy, 'token_type': 'bearer'})
m.cache.save_file('access_token.conf', legacy) m.cache.save_file('access_token.conf', legacy)
LOG('network_login: OK') LOG('network_login: OK')
+1 -4
View File
@@ -663,11 +663,8 @@ def login():
show_notification(addon.getLocalizedString(30166)) # Failed show_notification(addon.getLocalizedString(30166)) # Failed
def network_login(): def network_login():
# The account number and the device are found/registered automatically.
account_number = addon.getSetting('account_number') account_number = addon.getSetting('account_number')
if not account_number and not m.cache.load_file('account.json') and not m.cache.load_file('tokens.json'):
account_number = input_window(addon.getLocalizedString(30502)) # Account number
if not account_number: return
addon.setSetting('account_number', account_number)
# Keeps device_id.conf: clearing the session would register a new device. # Keeps device_id.conf: clearing the session would register a new device.
if m.try_network_login(account_number, force=True): if m.try_network_login(account_number, force=True):
show_notification(addon.getLocalizedString(30503), xbmcgui.NOTIFICATION_INFO) # Logged in show_notification(addon.getLocalizedString(30503), xbmcgui.NOTIFICATION_INFO) # Logged in