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
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