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:
1 parent
c4a0a7e97d
commit
c9f062206c
6 files changed
+80
-44
No files matched your search
@@ -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`
|
||||
|
||||
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).
|
||||
- **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).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="plugin.video.kodimovplus"
|
||||
name="KodiMov+"
|
||||
version="1.0.0+kodimov.3"
|
||||
version="1.0.0+kodimov.4"
|
||||
provider-name="Paco8, xocko">
|
||||
<requires>
|
||||
<!--- <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>
|
||||
<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>
|
||||
<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.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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+75
-37
@@ -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}'
|
||||
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):
|
||||
@@ -93,60 +95,96 @@ def find_account_number(m, setting=''):
|
||||
return (setting or '').strip()
|
||||
|
||||
|
||||
def network_login(m, account_number):
|
||||
"""Password-less login from the Movistar home network.
|
||||
1. autologin (only reachable inside Movistar's network) gives a 5-minute
|
||||
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)."""
|
||||
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
|
||||
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['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', '?')))
|
||||
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:
|
||||
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'):
|
||||
return False, '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 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'
|
||||
if not token: return False, 'autologin refused'
|
||||
|
||||
body = {'accountNumber': account_number, 'sessionUserProfile': 0, 'isKidProfile': False,
|
||||
'deviceId': device_id, 'streamMiscellanea': 'HTTPS', 'deviceManufacturerProduct': 'Firefox',
|
||||
'streamDRM': 'Widevine', 'streamFormat': 'DASH'}
|
||||
data = None
|
||||
for attempt in range(4):
|
||||
h = headers.copy()
|
||||
h['Authorization'] = get_auth('post', SOTER_INITDATA_URL, token)
|
||||
try:
|
||||
r = m.net.session.post(SOTER_INITDATA_URL, data=json.dumps(body), headers=h, timeout=(4, 15))
|
||||
data = r.json()
|
||||
break
|
||||
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
|
||||
status, text = _soter(m, 'post', SOTER_INITDATA_URL, token, body)
|
||||
try:
|
||||
legacy = json.loads(text).get('legacyAccessToken')
|
||||
except (ValueError, AttributeError):
|
||||
legacy = 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():
|
||||
return False, 'initData refused'
|
||||
return False, 'initData refused ({})'.format(status)
|
||||
m.save_key_file({'access_token': legacy, 'token_type': 'bearer'})
|
||||
m.cache.save_file('access_token.conf', legacy)
|
||||
LOG('network_login: OK')
|
||||
|
||||
@@ -663,11 +663,8 @@ def login():
|
||||
show_notification(addon.getLocalizedString(30166)) # Failed
|
||||
|
||||
def network_login():
|
||||
# The account number and the device are found/registered automatically.
|
||||
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.
|
||||
if m.try_network_login(account_number, force=True):
|
||||
show_notification(addon.getLocalizedString(30503), xbmcgui.NOTIFICATION_INFO) # Logged in
|
||||
|
||||
Reference in new issue
Block a user