diff --git a/README.md b/README.md index 60da82b..1630ac5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,13 @@ Fork of [Paco8's Movistar+ add-on](https://github.com/Paco8/plugin.video.movista 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). - **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). - **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). diff --git a/addon.xml b/addon.xml index 89278e4..80633cb 100644 --- a/addon.xml +++ b/addon.xml @@ -1,7 +1,7 @@ @@ -19,9 +19,10 @@ Movistar+ for Kodi, with automatic login from the Movistar home network Movistar+ para Kodi, con inicio de sesión automático desde la red de Movistar - 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. - 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+. - 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). + 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. + 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+. + 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.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). diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index a7d7700..d8c01a4 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -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 "" diff --git a/resources/language/resource.language.es_es/strings.po b/resources/language/resource.language.es_es/strings.po index 37c496c..bca1b87 100644 --- a/resources/language/resource.language.es_es/strings.po +++ b/resources/language/resource.language.es_es/strings.po @@ -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):" diff --git a/resources/lib/kodimov.py b/resources/lib/kodimov.py index 7aeca07..c9f4b41 100644 --- a/resources/lib/kodimov.py +++ b/resources/lib/kodimov.py @@ -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)) diff --git a/resources/lib/movistar.py b/resources/lib/movistar.py index f5c7ff2..eb539bb 100644 --- a/resources/lib/movistar.py +++ b/resources/lib/movistar.py @@ -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']} diff --git a/resources/lib/plugin.py b/resources/lib/plugin.py index 2004052..be4925e 100644 --- a/resources/lib/plugin.py +++ b/resources/lib/plugin.py @@ -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': diff --git a/resources/lib/proxy.py b/resources/lib/proxy.py index df8b198..44d2bec 100644 --- a/resources/lib/proxy.py +++ b/resources/lib/proxy.py @@ -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 diff --git a/resources/settings.xml b/resources/settings.xml index 1e5e4c5..02bb5c1 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -10,6 +10,7 @@ + diff --git a/service.py b/service.py index 3d06b49..6608441 100644 --- a/service.py +++ b/service.py @@ -46,14 +46,20 @@ if __name__ == '__main__': kodimov.restart_iptv_simple() if time.time() >= next_check: next_check = time.time() + 30 * 60 + exporting = False 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 reload_guide = '&reload_guide=1' if age_limit == 0 else '' if addon.getSettingBool('auto_export_epg'): # channels + programme guide 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 + ')') elif addon.getSettingBool('export_channels'): 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)') + 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()