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

+7 -1
View File
@@ -6,7 +6,13 @@ Fork of [Paco8's Movistar+ add-on](https://github.com/Paco8/plugin.video.movista
What it adds: What it adds:
- **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). - **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`). - **Plays LALIGA, DAZN LALIGA and Liga de Campeones** (25 channels, live and catch-up). Upstream 1.0.x doesn't list them (they are missing from the `amazon.tv` channel list it reads) and their streams answer 403 to the CDN token of the classic servers. KodiMov+ takes them from the web player's channel list and guide and opens them with a CDN token from Movistar's new servers; stream session and licence stay on the classic servers, like any other channel. The new servers only start a session for a device registered as a web player and, without a password, only from the Movistar home network, so:
- away from the Movistar home network these channels are not listed;
- a box that logged in with a password (or took over the login of the original add-on) is registered once more, as a web player: one extra device on the Movistar account;
- the CDN token lasts 12 hours and is renewed in the background, because the server that gives it out at times refuses everything for a few minutes.
- the licence server refuses the first request for one of these channels with error 4027 every time; upstream's "Try to fix error 4027" (register the device again and retry) gets the licence, and KodiMov+ shows the error only if that retry fails too.
- **Optional full HD**: left to itself, InputStream Adaptive starts every Movistar stream at 576p and never gets to the 720p/1080p versions (they use a different key). The setting "Always play at the best quality, up to 1080p" makes it start on the best one. Off by default.
- **Hides channels it can't play**: Movistar marks football and premium sports with `QMWatermark`; M+ Deportes, DAZN and DAZN Baloncesto play and are kept, the rest of the marked ones are hidden unless they come through the new servers (`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).
- **The TV guide stays current**: channel list and programme guide re-exported every 6 hours, numbered with Movistar's own dial numbers. Listings come from Movistar (with catch-up links) for its channels and from the public EPG_dobleM guide for the extras. - **The TV guide stays current**: channel list and programme guide re-exported every 6 hours, numbered with Movistar's own dial numbers. Listings come from Movistar (with catch-up links) for its channels and from the public EPG_dobleM guide for the extras.
- Fixes the programme guide export of upstream 1.0.0 (the amazon.tv guide is a flat list). - Fixes the programme guide export of upstream 1.0.0 (the amazon.tv guide is a flat list).
+5 -4
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.9" version="1.0.0+kodimov.10"
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"/> -->
@@ -19,9 +19,10 @@
<extension point="xbmc.addon.metadata"> <extension point="xbmc.addon.metadata">
<summary lang="en_GB">Movistar+ for Kodi, with automatic login from the Movistar home network</summary> <summary lang="en_GB">Movistar+ for Kodi, with automatic login from the Movistar home network</summary>
<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, plays the LALIGA, DAZN LALIGA and Liga de Campeones channels through Movistar's new servers (from the Movistar home network), hides the channels it can't play, 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, reproduce los canales de LALIGA, DAZN LALIGA y Liga de Campeones a través de los servidores nuevos de Movistar (desde la red de Movistar de casa), oculta los canales que no puede reproducir, 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.9: after Kodi starts or the add-on updates, a new programme guide shows up in Kodi within a couple of minutes instead of at the next hourly reload (only while nothing is playing). <news>1.0.0+kodimov.10: the LALIGA, DAZN LALIGA and Liga de Campeones channels are back, live and catch-up, on boxes connected to the Movistar home network (their streams are now served only through Movistar's new servers). New setting to always play at the best quality, up to 1080p, instead of the 576p Kodi stays at on automatic (off by default). The "Error 4027" message no longer pops up when the add-on recovers from it by itself.
1.0.0+kodimov.9: after Kodi starts or the add-on updates, a new programme guide shows up in Kodi within a couple of minutes instead of at the next hourly reload (only while nothing is playing).
1.0.0+kodimov.8: the programme guide refresh no longer fails at random inside Kodi, and a failed refresh is tried again half an hour later instead of 6 hours later. 1.0.0+kodimov.8: the programme guide refresh no longer fails at random inside Kodi, and a failed refresh is tried again half an hour later instead of 6 hours later.
1.0.0+kodimov.7: the programme guide has the synopsis, picture, genre, year and age rating of each programme. IPTV Simple now reloads the guide every hour instead of once a day, so a new guide shows up in Kodi by itself (existing TV guide setups are switched over once). 1.0.0+kodimov.7: the programme guide has the synopsis, picture, genre, year and age rating of each programme. IPTV Simple now reloads the guide every hour instead of once a day, so a new guide shows up in Kodi by itself (existing TV guide setups are switched over once).
1.0.0+kodimov.6: the programme guide refreshes on its own again on boxes first set up with kodimov.1, where the automatic guide export had stayed off (the guide ran out after 2 days). 1.0.0+kodimov.6: the programme guide refreshes on its own again on boxes first set up with kodimov.1, where the automatic guide export had stayed off (the guide ran out after 2 days).
@@ -643,7 +643,7 @@ msgid "Log in automatically from the Movistar home network"
msgstr "" msgstr ""
msgctxt "#30506" 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 "" msgstr ""
msgctxt "#30507" msgctxt "#30507"
@@ -657,3 +657,11 @@ msgstr ""
msgctxt "#30509" msgctxt "#30509"
msgid "Log in first: there is no channel list yet" msgid "Log in first: there is no channel list yet"
msgstr "" 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" msgstr "Iniciar sesión automáticamente desde la red de Movistar"
msgctxt "#30506" 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 "Ocultar los canales que no se pueden reproducir (LALIGA, Liga de Campeones)" msgstr "Ocultar los canales que no se pueden reproducir"
msgctxt "#30507" msgctxt "#30507"
msgid "Add free Galician channels (TVG2, TeleVigo) to the TV guide" 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" msgid "Log in first: there is no channel list yet"
msgstr "Inicia sesión primero: todavía no hay lista de canales" 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" #~ msgctxt "#30040"
#~ msgid "Use only for these languages (e.g.: es en pt):" #~ msgid "Use only for these languages (e.g.: es en pt):"
#~ msgstr "Usar solo para estos idiomas (ej: 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 # - takes over the login of plugin.video.movistarplus on first run
# - logs in from the Movistar home network without a password # - logs in from the Movistar home network without a password
# - hides channels this add-on can't play # - 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 # - extra free channels for the TV guide
from __future__ import unicode_literals, absolute_import, division from __future__ import unicode_literals, absolute_import, division
@@ -22,8 +24,9 @@ ORIGINAL_ADDON = 'plugin.video.movistarplus'
CLASSIC_ISSUER = 'http://www.movistarplus.es' CLASSIC_ISSUER = 'http://www.movistarplus.es'
# Movistar marks the football and premium sports channels with QMWatermark. # 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 = { WATERMARKED_BUT_PLAYABLE = {
'CPDEP', 'ARTHUR', 'USOP2', 'USOP3', 'USOP11', 'MULTI8', # M+ Deportes 1-6 'CPDEP', 'ARTHUR', 'USOP2', 'USOP3', 'USOP11', 'MULTI8', # M+ Deportes 1-6
'M1SD', 'M2SD', 'DAZN3', 'DAZN4', # DAZN 1-4 '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_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_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_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): def is_playable(channel):
return not channel.get('watermark') or channel['id'] in WATERMARKED_BUT_PLAYABLE 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): def migrate_from_original(config_directory):
"""Copies the login of plugin.video.movistarplus (tokens, registered device, """Copies the login of plugin.video.movistarplus (tokens, registered device,
credentials) the first time KodiMov+ runs, so no new device is added to the 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: except Exception as e:
LOG('soter {} failed: {}'.format(url.split('?')[0], 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', '?'))) 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), '' return getattr(r, 'status_code', 0), ''
@@ -174,23 +201,232 @@ def network_login(m, account_number=''):
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'
body = {'accountNumber': account_number, 'sessionUserProfile': 0, 'isKidProfile': False, status, data = _init_data(m, token, account_number, device_id)
'deviceId': device_id, 'streamMiscellanea': 'HTTPS', 'deviceManufacturerProduct': 'Firefox', legacy = data.get('legacyAccessToken')
'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
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 ({})'.format(status) 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)
# 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') LOG('network_login: OK')
return True, '' 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(): def extra_channels_m3u8():
"""Returns [(channel number, m3u8 entry)].""" """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)) 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 add_extra_info = True
hide_unplayable = True hide_unplayable = True
add_extra_channels = True add_extra_channels = True
add_web_channels = True
#dplayer = 'webplayer' #dplayer = 'webplayer'
#device_code = 'WP_OTT' #device_code = 'WP_OTT'
#manufacturer = 'Firefox' #manufacturer = 'Firefox'
@@ -548,6 +549,10 @@ class Movistar(object):
groups.setdefault(p['Canal']['CodCadenaTv'], []).append(p) groups.setdefault(p['Canal']['CodCadenaTv'], []).append(p)
data = list(groups.values()) 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: for ch in data:
if not ch: continue if not ch: continue
id = ch[0]['Canal']['CodCadenaTv'] id = ch[0]['Canal']['CodCadenaTv']
@@ -681,6 +686,10 @@ class Movistar(object):
if not 'error' in data: if not 'error' in data:
self.cache.save_file(cache_filename, json.dumps(data, ensure_ascii=False)) 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 = [] res = []
if 'error' in data: return res if 'error' in data: return res
@@ -709,6 +718,7 @@ class Movistar(object):
t['info']['playcount'] = 1 # Set as watched t['info']['playcount'] = 1 # Set as watched
t['uid'] = c.get('Uid') t['uid'] = c.get('Uid')
t['watermark'] = bool(c.get('QMWatermark')) t['watermark'] = bool(c.get('QMWatermark'))
t['new_api'] = bool(c.get('new_api'))
if False and 'links' in c: if False and 'links' in c:
for l in c['links']: for l in c['links']:
if l['rel'] == 'watermarks': if l['rel'] == 'watermarks':
@@ -727,8 +737,11 @@ class Movistar(object):
res.append(t) 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: if self.hide_unplayable:
res = [t for t in res if kodimov.is_playable(t)] res = kodimov.playable_channels(self, res)
return res return res
def get_channels_with_epg(self): def get_channels_with_epg(self):
@@ -1386,7 +1399,8 @@ class Movistar(object):
shutil.copyfile(self.cache.config_directory + 'credentials.json', filename) shutil.copyfile(self.cache.config_directory + 'credentials.json', filename)
def delete_session_files(self): 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) self.cache.remove_file(f)
def get_profile_image_url(self, img_id): def get_profile_image_url(self, img_id):
@@ -1420,6 +1434,7 @@ class Movistar(object):
t['logo'] = c['art']['icon'] t['logo'] = c['art']['icon']
t['preset'] = c['dial'] t['preset'] = c['dial']
args = urlencode({'action': 'play', 'stype': 'tv', 'id': c['id'], 'url': c['url'], 'session_request': c['session_request']}) 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 t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args
if 'cas_id' in c: t['cas_id'] = c['cas_id'] if 'cas_id' in c: t['cas_id'] = c['cas_id']
res.append(t) res.append(t)
@@ -1454,6 +1469,7 @@ class Movistar(object):
for channel in channels: for channel in channels:
id = channel['id'] id = channel['id']
if not id in epg: continue if not id in epg: continue
new_api = '&new_api=1' if channel.get('new_api') else ''
res[id] = [] res[id] = []
for e in epg[id]: for e in epg[id]:
t = {} t = {}
@@ -1470,7 +1486,7 @@ class Movistar(object):
url = 'https://grmovistar.imagenio.telefonica.net/asfe/rest/tvMediaURLs?tvProgram.id='+ program_id +'&svc=startover' url = 'https://grmovistar.imagenio.telefonica.net/asfe/rest/tvMediaURLs?tvProgram.id='+ program_id +'&svc=startover'
session_request = '{"contentID":' + program_id +',"streamType":"CUTV"}' session_request = '{"contentID":' + program_id +',"streamType":"CUTV"}'
args = urlencode({'action': 'play', 'stype': 'u7d', 'id': program_id, 'url': url, 'session_request': session_request}) 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']: 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']} 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' #headers += '&Host=wv-ottlic-f3.imagenio.telefonica.net'
manifest_headers = 'User-Agent=' + useragent 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) cdn_token = m.cache.load('cdn.conf', 60)
if not cdn_token: if not cdn_token:
cdn_token = m.get_cdntoken() 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', 'mpd')
#play_item.setProperty('inputstream.adaptive.manifest_type', 'hls') #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.stream_headers', manifest_headers)
play_item.setProperty('inputstream.adaptive.manifest_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) list_item.addContextMenuItems(actions)
url = get_url(action='play', id=t['id'], url=t['url'], session_request=t['session_request'], stype=t['stream_type']) 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 'show_id' in t: url += '&show_id={}'.format(t['show_id'])
if 'uid' in t: url += '&uid={}'.format(t['uid']) if 'uid' in t: url += '&uid={}'.format(t['uid'])
xbmcplugin.addDirectoryItem(_handle, url, list_item, False) xbmcplugin.addDirectoryItem(_handle, url, list_item, False)
@@ -862,6 +883,10 @@ def router(paramstring):
network_login() network_login()
elif params['action'] == 'export_channels_now': elif params['action'] == 'export_channels_now':
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': elif params['action'] == 'export_key':
export_key() export_key()
elif params['action'] == 'export_credentials': elif params['action'] == 'export_credentials':
+7 -2
View File
@@ -241,12 +241,17 @@ class RequestHandler(BaseHTTPRequestHandler):
LOG('license response: {}'.format(license_data)) LOG('license response: {}'.format(license_data))
d = try_load_json(license_data) d = try_load_json(license_data)
if d and 'errorCode' in d: if d and 'errorCode' in d:
from .gui import show_notification # KodiMov+: the error is shown only when the retry below
show_notification('Error {}: {}'.format(d['errorCode'], d['message'])) # 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 d['errorCode'] == 4027:
if not reregister_needed and addon.getSettingBool('reregister'): if not reregister_needed and addon.getSettingBool('reregister'):
reregister_needed = True reregister_needed = True
continue continue
from .gui import show_notification
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
else: else:
LOG('license response: {}'.format(encode_base64(license_data))) LOG('license response: {}'.format(encode_base64(license_data)))
break break
+1
View File
@@ -10,6 +10,7 @@
<setting label="30020" type="bool" id="channels_with_epg" default="false"/> <setting label="30020" type="bool" id="channels_with_epg" default="false"/>
<setting label="30021" type="bool" id="add_extra_info" default="false"/> <setting label="30021" type="bool" id="add_extra_info" default="false"/>
<setting label="30033" type="bool" id="uhd" 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 label="30042" type="number" id="expdays" default="30"/>
<setting id="use_ttml2ssa" type="bool" label="$ADDON[script.module.ttml2ssa 32202]" default="false"/> <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"/> <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"/>
+6
View File
@@ -46,14 +46,20 @@ if __name__ == '__main__':
kodimov.restart_iptv_simple() kodimov.restart_iptv_simple()
if time.time() >= next_check: if time.time() >= next_check:
next_check = time.time() + 30 * 60 next_check = time.time() + 30 * 60
exporting = False
age_limit, max_age = max_age, 6 * 3600 - 120 age_limit, max_age = max_age, 6 * 3600 - 120
# After Kodi starts (or this add-on updates) also get the new guide into Kodi now # After Kodi starts (or this add-on updates) also get the new guide into Kodi now
reload_guide = '&reload_guide=1' if age_limit == 0 else '' reload_guide = '&reload_guide=1' if age_limit == 0 else ''
if addon.getSettingBool('auto_export_epg'): # channels + programme guide if addon.getSettingBool('auto_export_epg'): # channels + programme guide
if file_age(os.path.join(profile_dir, 'epg.xml')) > age_limit: if file_age(os.path.join(profile_dir, 'epg.xml')) > age_limit:
exporting = True
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now&quiet=1' + reload_guide + ')') xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now&quiet=1' + reload_guide + ')')
elif addon.getSettingBool('export_channels'): elif addon.getSettingBool('export_channels'):
if file_age(os.path.join(profile_dir, 'channels.m3u8')) > age_limit: if file_age(os.path.join(profile_dir, 'channels.m3u8')) > age_limit:
exporting = True
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_channels_now)') xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_channels_now)')
if not exporting:
# Keeps the pass for the LALIGA / Liga de Campeones streams fresh
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=new_api_keep_warm)')
proxy.stop() proxy.stop()