Compare commits
13
Commits
119002fbbf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62bf07a2b | ||
|
|
7873b50203 | ||
|
|
69a37ff064 | ||
|
|
b3bc561c35 | ||
|
|
e72e3d4902 | ||
|
|
e16dd66f63 | ||
|
|
5df60a1fb8 | ||
|
|
9faaa482fc | ||
|
|
09d6a68f74 | ||
|
|
d74c33011b | ||
|
|
3940d1b3e6 | ||
|
|
c9f062206c | ||
|
|
c4a0a7e97d |
No files matched your search
@@ -4,13 +4,23 @@ 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`).
|
||||
- **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.
|
||||
- **Gets along with Radios de Vigo** (`service.radios.vigo`), which keeps its own IPTV Simple instance for the Radio section. Kodi only lets *Use channel numbers from backend* be changed while it applies (one PVR instance loaded, or several and *always* on), so with a second instance it had refused the setting. Both add-ons now check it every so often: if it is hidden they turn *always* on, and they turn the setting itself on once; neither ever turns anything off, and turning it off by hand afterwards is respected. They also take turns to restart IPTV Simple, through the `pvr.iptvsimple.restarting` property of Kodi's home window.
|
||||
- **One IPTV Simple instance per channel list**: the TV guide setup rewrites the instance that already reads KodiMov+'s list (one set up with the original add-on, or by hand) instead of adding `instance-settings-91.xml` next to it, and the service deletes any extra one at Kodi start (keeping the one that isn't 91, which holds what was watched): two of them showed every channel twice.
|
||||
- Fixes the programme guide export of upstream 1.0.0 (the amazon.tv guide is a flat list).
|
||||
- Its own proxy port (57031), so it can run next to the original.
|
||||
- Its own proxy port (57031), so it can run next to the original. After an update the new proxy waits for the old one to free the port instead of moving to a random one, so the channel being watched keeps playing (upstream sets `allow_reuse_address` and `SO_REUSEADDR` too late to take effect).
|
||||
- **Restarts IPTV Simple only while idle**: nothing playing and no key pressed for 10 seconds (a channel opened during a restart fails). It waits for that as long as Kodi runs: after the box was off, Kodi first loads the old guide file and keeps it (no current programmes) until the reload. After start or update a single guide reload also picks up changed instance files.
|
||||
- **Removes empty IPTV Simple instances** (no channel list, like the "Migrated Add-on Config" Kodi creates when IPTV Simple is installed again) once KodiMov+'s own instance is there.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.2"
|
||||
version="1.0.0+kodimov.14"
|
||||
provider-name="Paco8, xocko">
|
||||
<requires>
|
||||
<!--- <import addon="xbmc.python" version="2.25.0"/> -->
|
||||
@@ -19,9 +19,21 @@
|
||||
<extension point="xbmc.addon.metadata">
|
||||
<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>
|
||||
<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.2: programme guide for every channel (Movistar + TVG2/TeleVigo), TeleVigo on 15, silent background refresh.
|
||||
<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, 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.14: the programme guide is no longer left empty for most channels after the box was off for a while and the remote is in use: the guide reload now waits for a 10-second pause instead of 30 seconds and doesn't give up after 10 minutes. Empty IPTV Simple instances ("Migrated Add-on Config", left when IPTV Simple is installed again) are removed.
|
||||
1.0.0+kodimov.13: updating the add-on no longer stops the channel being watched (the proxy got a new port and the stream lost it). IPTV Simple is restarted only while nothing plays and nobody has used the remote for 30 seconds, and fewer times after an update.
|
||||
1.0.0+kodimov.12: no more channels twice in the TV guide on boxes where the TV guide setup was run over an existing setup (the extra IPTV Simple instance is removed, and the setup now rewrites the existing one). LALIGA, DAZN LALIGA and Liga de Campeones no longer go missing for 6 hours when Movistar's new servers refuse the box's registration as a web player once: it is tried again, and the TV guide list is rewritten half an hour later.
|
||||
1.0.0+kodimov.11: Movistar's channel numbers also show on boxes with more than one IPTV Simple instance (Radios de Vigo, a leftover empty one), where Kodi had refused the setting and numbered the channels 1, 2, 3...; existing TV guide setups are put right by themselves. KodiMov+ and Radios de Vigo no longer restart IPTV Simple at the same time.
|
||||
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).
|
||||
1.0.0+kodimov.5: TV guide setup writes the programme guide before creating the IPTV Simple settings, so the guide isn't empty on a new box.
|
||||
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>
|
||||
<disclaimer lang="es_ES">Este addon no ha sido realizado ni apoyado por Movistar+. Todos los nombres de productos, logotipos y marcas registradas mencionados en este proyecto son propiedad de sus respectivos dueños.</disclaimer>
|
||||
|
||||
@@ -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"
|
||||
@@ -653,3 +653,15 @@ msgstr ""
|
||||
msgctxt "#30508"
|
||||
msgid "Keep the TV guide channel list up to date"
|
||||
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 ""
|
||||
@@ -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"
|
||||
@@ -617,6 +617,18 @@ msgctxt "#30508"
|
||||
msgid "Keep the TV guide channel list up to date"
|
||||
msgstr "Mantener al día la lista de canales de la guía"
|
||||
|
||||
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):"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,8 +15,8 @@ def save_iptv_settings(filename, name, addon_id, epg_url=None):
|
||||
<setting id="m3uCache" default="true">true</setting>
|
||||
<setting id="startNum" default="true">1</setting>
|
||||
<setting id="numberByOrder" default="true">false</setting>
|
||||
<setting id="m3uRefreshMode" default="true">0</setting>
|
||||
<setting id="m3uRefreshIntervalMins" default="true">60</setting>
|
||||
<setting id="m3uRefreshMode">1</setting>
|
||||
<setting id="m3uRefreshIntervalMins">60</setting>
|
||||
<setting id="m3uRefreshHour" default="true">4</setting>
|
||||
<setting id="connectioncheckinterval" default="true">10</setting>
|
||||
<setting id="connectionchecktimeout" default="true">20</setting>
|
||||
@@ -82,7 +82,7 @@ def save_iptv_settings(filename, name, addon_id, epg_url=None):
|
||||
|
||||
content += '''
|
||||
<setting id="catchupEnabled">true</setting>
|
||||
<setting id="catchupQueryFormat">&start_time={utc}&end_time={utcend}</setting>
|
||||
<setting id="catchupQueryFormat">&start_time={utc}&end_time={utcend}</setting>
|
||||
<setting id="allChannelsCatchupMode">2</setting>'''
|
||||
|
||||
content += '''
|
||||
|
||||
+536
-37
@@ -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
|
||||
@@ -46,12 +49,38 @@ 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'
|
||||
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
|
||||
@@ -93,64 +122,321 @@ 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))
|
||||
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'
|
||||
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', '?')))
|
||||
if attempt + 1 < attempts: time.sleep(5)
|
||||
return getattr(r, 'status_code', 0), ''
|
||||
|
||||
|
||||
def network_login(m, account_number=''):
|
||||
"""Password-less login from the Movistar home network.
|
||||
1. autologin gives a 5-minute token for the new servers;
|
||||
2. on a fresh box: accountInfo gives the account number and the device is
|
||||
registered (one new device on the account, like a password login);
|
||||
3. initData trades a device-bound token for a legacyAccessToken, which the
|
||||
classic servers this add-on uses accept and renew for 10 days at a time.
|
||||
Returns (success, reason)."""
|
||||
import uuid
|
||||
device_id = (m.cache.load_file('device_id.conf') or '').strip().strip('"')
|
||||
try:
|
||||
if not account_number or not device_id:
|
||||
token = _autologin(m, device_id or uuid.uuid4().hex)
|
||||
if not account_number:
|
||||
status, text = _soter(m, 'get', SOTER_ACCOUNTINFO_URL, token)
|
||||
try:
|
||||
account_number = json.loads(text)['ofertas'][0]['accountNumber']
|
||||
except (ValueError, KeyError, IndexError, TypeError):
|
||||
return False, 'account not found ({})'.format(status)
|
||||
if not device_id:
|
||||
status, text = _soter(m, 'post', SOTER_DEVICES_URL.format(account=account_number), token, attempts=1)
|
||||
new_id = text.strip().strip('"')
|
||||
if status not in (200, 201) or len(new_id) != 32:
|
||||
return False, 'device registration refused ({})'.format(status)
|
||||
device_id = new_id
|
||||
# Saved straight away, so a retry reuses it instead of adding another.
|
||||
m.cache.save_file('device_id.conf', device_id)
|
||||
LOG('network_login: registered device {}...'.format(device_id[:5]))
|
||||
token = _autologin(m, device_id)
|
||||
except Exception as e:
|
||||
LOG('network_login: autologin failed: {}'.format(e))
|
||||
return False, 'not on the Movistar network'
|
||||
if not token: return False, 'autologin refused'
|
||||
|
||||
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'}
|
||||
data = None
|
||||
for attempt in range(4):
|
||||
h = headers.copy()
|
||||
h['Authorization'] = get_auth('post', SOTER_INITDATA_URL, token)
|
||||
status, text = _soter(m, 'post', SOTER_INITDATA_URL, token, body)
|
||||
try:
|
||||
r = m.net.session.post(SOTER_INITDATA_URL, data=json.dumps(body), headers=h, timeout=(4, 15))
|
||||
data = r.json()
|
||||
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)
|
||||
# Like the other calls, it at times gets a 401 error page for no reason
|
||||
# (seen 2026-11-13: refused once, registered on the next try)
|
||||
status, text = _soter(m, 'post', SOTER_DEVICES_URL.format(account=account_number), token, attempts=3)
|
||||
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
|
||||
except Exception:
|
||||
# The new servers sometimes answer with an HTML error page; retry.
|
||||
LOG('network_login: initData attempt {} failed ({})'.format(attempt + 1, getattr(r, 'status_code', '?')))
|
||||
time.sleep(3)
|
||||
legacy = data.get('legacyAccessToken') if isinstance(data, dict) else None
|
||||
props = m.get_token_properties(legacy) if legacy else None
|
||||
if not props or props.get('iss') != CLASSIC_ISSUER or props.get('exp', 0) < time.time():
|
||||
return False, 'initData refused'
|
||||
m.save_key_file({'access_token': legacy, 'token_type': 'bearer'})
|
||||
m.cache.save_file('access_token.conf', legacy)
|
||||
LOG('network_login: OK')
|
||||
return True, ''
|
||||
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 new_api_failed(profile_dir):
|
||||
"""True if the last try to reach the new servers failed and none ever
|
||||
worked: the TV guide list was written without LALIGA, DAZN LALIGA and
|
||||
Liga de Campeones, so the service exports it again sooner."""
|
||||
import glob
|
||||
for stamp in glob.glob(os.path.join(profile_dir, 'account_*', 'new_api.stamp')):
|
||||
if not os.path.exists(os.path.join(os.path.dirname(stamp), 'new_api.json')): return True
|
||||
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():
|
||||
@@ -162,7 +448,9 @@ def extra_channels_m3u8():
|
||||
def write_if_changed(filename, content):
|
||||
try:
|
||||
with io.open(filename, 'r', encoding='utf-8', newline='') as f:
|
||||
if f.read() == content: return False
|
||||
if f.read() == content:
|
||||
os.utime(filename, None) # the service reads the time as "last refreshed"
|
||||
return False
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
tmp = filename + '.tmp'
|
||||
@@ -172,6 +460,217 @@ def write_if_changed(filename, content):
|
||||
return True
|
||||
|
||||
|
||||
def set_iptv_refresh(pvr_dir, addon_id, minutes=60):
|
||||
"""Makes the IPTV Simple instances that read this add-on's files reload the
|
||||
channel list and programme guide every few minutes. Each reload also makes
|
||||
Kodi fetch the guide again. TV guide setups before kodimov.7 reloaded only
|
||||
once a day, at 4:00. Returns True if a file changed (IPTV Simple must be
|
||||
restarted to use it)."""
|
||||
import glob
|
||||
import re
|
||||
changed = False
|
||||
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
|
||||
with io.open(filename, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if 'plugin.video.{}/channels.m3u8'.format(addon_id) not in content: continue
|
||||
new = content
|
||||
for setting, value in (('m3uRefreshMode', '1'), ('m3uRefreshIntervalMins', str(minutes))):
|
||||
new = re.sub(r'<setting id="{}"[^>]*?(/>|>[^<]*</setting>)'.format(setting),
|
||||
'<setting id="{}">{}</setting>'.format(setting, value), new)
|
||||
if new != content:
|
||||
write_if_changed(filename, new)
|
||||
LOG('set_iptv_refresh: {} minutes in {}'.format(minutes, filename))
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def reload_iptv_guide(pvr_dir, addon_id):
|
||||
"""Gets a new programme guide into Kodi now instead of at the next hourly
|
||||
reload. Restarting IPTV Simple isn't enough: Kodi keeps its own copy of the
|
||||
guide and fetches it again only when IPTV Simple reloads, which happens on
|
||||
its timer. So restart it with a 1-minute timer, wait for that reload, then
|
||||
restart it with the hourly timer. Restarting stops live TV, so this does
|
||||
nothing if something is playing."""
|
||||
import xbmc
|
||||
monitor = xbmc.Monitor()
|
||||
if not wait_until_idle(): return
|
||||
if not set_iptv_refresh(pvr_dir, addon_id, 1): return
|
||||
try:
|
||||
restart_iptv_simple()
|
||||
monitor.waitForAbort(90)
|
||||
finally:
|
||||
set_iptv_refresh(pvr_dir, addon_id, 60)
|
||||
if wait_until_idle(): # until then it keeps reloading every minute
|
||||
restart_iptv_simple()
|
||||
|
||||
|
||||
def is_idle(seconds=10):
|
||||
"""Nothing playing and nobody pressed a key for a few seconds. Restarting
|
||||
IPTV Simple stops live TV, and a channel opened while it restarts fails.
|
||||
Short on purpose: the reload is most needed right when someone is
|
||||
browsing a TV guide with missing programmes."""
|
||||
import xbmc
|
||||
return not xbmc.Player().isPlaying() and xbmc.getGlobalIdleTime() >= seconds
|
||||
|
||||
|
||||
def wait_until_idle(timeout=None):
|
||||
"""Waits until is_idle(), by default for as long as Kodi runs. False if
|
||||
Kodi is closing or the timeout ran out."""
|
||||
import xbmc
|
||||
monitor = xbmc.Monitor()
|
||||
end = time.time() + timeout if timeout else None
|
||||
while not is_idle():
|
||||
if monitor.waitForAbort(5) or (end and time.time() > end): return False
|
||||
return not monitor.abortRequested()
|
||||
|
||||
|
||||
# IPTV Simple is shared with other add-ons (Radios de Vigo keeps its own
|
||||
# instance for the Radio section). Restarting it restarts every instance, so
|
||||
# the add-ons take turns: whoever restarts it says so in this property of
|
||||
# Kodi's home window (the time it started), and the others wait.
|
||||
IPTV_RESTART_PROPERTY = 'pvr.iptvsimple.restarting'
|
||||
# When this add-on last restarted it, so the service can skip a restart that
|
||||
# one from the plugin (the guide reload) already did
|
||||
LAST_RESTART_PROPERTY = 'kodimovplus.iptvsimple.restarted'
|
||||
|
||||
|
||||
def last_iptv_restart():
|
||||
import xbmcgui
|
||||
try:
|
||||
return float(xbmcgui.Window(10000).getProperty(LAST_RESTART_PROPERTY) or 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def restart_iptv_simple():
|
||||
"""Restarts IPTV Simple so it loads its instance settings again."""
|
||||
import xbmc
|
||||
import xbmcgui
|
||||
window = xbmcgui.Window(10000)
|
||||
for _ in range(40): # another add-on is restarting it: wait (a turn older than 20 s is stale)
|
||||
try:
|
||||
busy = 0 <= time.time() - float(window.getProperty(IPTV_RESTART_PROPERTY) or 0) < 20
|
||||
except ValueError:
|
||||
busy = False
|
||||
if not busy: break
|
||||
xbmc.sleep(1000)
|
||||
window.setProperty(IPTV_RESTART_PROPERTY, str(time.time()))
|
||||
window.setProperty(LAST_RESTART_PROPERTY, str(time.time()))
|
||||
try:
|
||||
for enabled in (False, True):
|
||||
xbmc.executeJSONRPC(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Addons.SetAddonEnabled',
|
||||
'params': {'addonid': 'pvr.iptvsimple', 'enabled': enabled}}))
|
||||
xbmc.sleep(3000)
|
||||
finally:
|
||||
window.clearProperty(IPTV_RESTART_PROPERTY)
|
||||
|
||||
|
||||
SETUP_INSTANCE_FILE = 'instance-settings-91.xml' # written by "Create IPTV settings"
|
||||
|
||||
|
||||
def iptv_instance_files(pvr_dir, addon_id):
|
||||
"""The IPTV Simple instance files that read this add-on's channel list,
|
||||
the one to keep first: a box set up before (with the original add-on, or
|
||||
by hand) has its instance besides 91, and that one holds what was watched;
|
||||
91 is the one the TV guide setup writes."""
|
||||
import glob
|
||||
res = []
|
||||
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
|
||||
try:
|
||||
with io.open(filename, 'r', encoding='utf-8') as f:
|
||||
if 'plugin.video.{}/channels.m3u8'.format(addon_id) in f.read(): res.append(filename)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
def number(filename):
|
||||
try:
|
||||
return int(os.path.basename(filename)[len('instance-settings-'):-len('.xml')])
|
||||
except ValueError:
|
||||
return 0
|
||||
res.sort(key=lambda f: (os.path.basename(f) == SETUP_INSTANCE_FILE, number(f)))
|
||||
return res
|
||||
|
||||
|
||||
def has_iptv_instance(pvr_dir, addon_id):
|
||||
"""True if an IPTV Simple instance reads this add-on's channel list."""
|
||||
return bool(iptv_instance_files(pvr_dir, addon_id))
|
||||
|
||||
|
||||
def _empty_instance_files(pvr_dir):
|
||||
"""Instances with no channel list at all, like the "Migrated Add-on Config"
|
||||
Kodi creates when IPTV Simple is installed again. They show nothing and
|
||||
count as a second PVR instance (see ensure_channel_numbers)."""
|
||||
import glob
|
||||
import re
|
||||
def value(content, setting):
|
||||
match = re.search(r'<setting id="{}"[^>]*?(?:/>|>([^<]*)</setting>)'.format(setting), content)
|
||||
return ((match and match.group(1)) or '').strip()
|
||||
res = []
|
||||
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
|
||||
try:
|
||||
with io.open(filename, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except (IOError, OSError):
|
||||
continue
|
||||
if not value(content, 'm3uPath') and not value(content, 'm3uUrl'): res.append(filename)
|
||||
return res
|
||||
|
||||
|
||||
def remove_extra_instances(pvr_dir, addon_id):
|
||||
"""Two instances reading the same channel list put every channel twice in
|
||||
the TV guide. That happened when the TV guide setup (instance 91) was run
|
||||
on a box already set up with another instance. Keeps one and deletes the
|
||||
others, and deletes empty instances (no channel list) once this add-on's
|
||||
is there. Returns True if a file was deleted (IPTV Simple must be
|
||||
restarted to drop it)."""
|
||||
ours = iptv_instance_files(pvr_dir, addon_id)
|
||||
if not ours: return False
|
||||
removed = False
|
||||
for filename in ours[1:] + _empty_instance_files(pvr_dir):
|
||||
try:
|
||||
os.remove(filename)
|
||||
LOG('remove_extra_instances: removed {}'.format(filename))
|
||||
removed = True
|
||||
except OSError as e:
|
||||
LOG('remove_extra_instances: {}: {}'.format(filename, e))
|
||||
return removed
|
||||
|
||||
|
||||
def ensure_channel_numbers(marker):
|
||||
"""Makes Kodi show the channel numbers of the list (Movistar's dial
|
||||
numbers) instead of 1, 2, 3...
|
||||
|
||||
Kodi only lets "use channel numbers from backend" be read or changed while
|
||||
it applies: with exactly one PVR instance loaded, or with several and the
|
||||
"always" setting on. Radios de Vigo adds an instance of IPTV Simple and
|
||||
some boxes carry a leftover empty one, so the setting can stop applying
|
||||
after it was turned on. This is called now and then and puts it right:
|
||||
- hidden: turns "always" on, which brings the stored value back;
|
||||
- off and never turned on by this add-on (no marker file): turns it on;
|
||||
- off after this add-on turned it on once: the user's choice, left alone.
|
||||
It never turns anything off, so it can't undo what Radios de Vigo needs.
|
||||
Returns True when Kodi is using the numbers of the list."""
|
||||
import xbmc
|
||||
def rpc(method, **params):
|
||||
try:
|
||||
return json.loads(xbmc.executeJSONRPC(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params})))
|
||||
except ValueError:
|
||||
return {}
|
||||
def numbers():
|
||||
return (rpc('Settings.GetSettingValue', setting='pvrmanager.usebackendchannelnumbers').get('result') or {}).get('value')
|
||||
value = numbers()
|
||||
if value is None:
|
||||
rpc('Settings.SetSettingValue', setting='pvrmanager.usebackendchannelnumbersalways', value=True)
|
||||
value = numbers()
|
||||
if value is None: return False # IPTV Simple isn't loaded right now: try later
|
||||
if value is False and not os.path.exists(marker):
|
||||
rpc('Settings.SetSettingValue', setting='pvrmanager.usebackendchannelnumbers', value=True)
|
||||
value = numbers()
|
||||
if value is True and not os.path.exists(marker):
|
||||
write_if_changed(marker, '1')
|
||||
LOG('ensure_channel_numbers: on')
|
||||
return value is True
|
||||
|
||||
|
||||
def extra_channels_xmltv(session):
|
||||
"""Returns the <channel> and <programme> elements for the extra channels,
|
||||
taken from a public XMLTV guide and renamed to their tvg-id."""
|
||||
|
||||
@@ -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']
|
||||
@@ -564,6 +569,13 @@ class Movistar(object):
|
||||
if 'TituloHorLinea2' in p:
|
||||
pr['desc2'] = p['TituloHorLinea2']
|
||||
pr['show_id'] = p['ShowId']
|
||||
# Synopsis, genre, year, age rating and picture come with the guide
|
||||
pr['plot'] = p.get('Resena') or ''
|
||||
pr['genre'] = p.get('GeneroComAntena') or ''
|
||||
pr['year'] = p.get('Anno') or ''
|
||||
pr['age_rating'] = (p.get('NivelMoral') or {}).get('Id') or ''
|
||||
images = {i.get('id'): i.get('uri') for i in (p.get('Imagenes') or [])}
|
||||
pr['image'] = images.get('horizontal') or images.get('default') or ''
|
||||
if 'SerialId' in p:
|
||||
pr['serie_id'] = p['SerialId']
|
||||
pr['id'] = p['Id']
|
||||
@@ -674,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
|
||||
|
||||
@@ -702,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':
|
||||
@@ -720,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):
|
||||
@@ -1379,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):
|
||||
@@ -1413,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)
|
||||
@@ -1447,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 = {}
|
||||
@@ -1454,13 +1477,16 @@ class Movistar(object):
|
||||
t['subtitle'] = e['desc2']
|
||||
t['start'] = datetime.utcfromtimestamp(e['start']/1000).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
t['stop'] = datetime.utcfromtimestamp(e['end']/1000).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
for key, field in (('description', 'plot'), ('image', 'image'), ('genre', 'genre'),
|
||||
('year', 'year'), ('age_rating', 'age_rating')):
|
||||
if e.get(field): t[key] = e[field]
|
||||
|
||||
if True:
|
||||
program_id = str(e['show_id'])
|
||||
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']}
|
||||
@@ -1523,8 +1549,9 @@ class Movistar(object):
|
||||
if only_subscribed and not ch['subscribed']: continue
|
||||
for e in epg[ch['id']]:
|
||||
#LOG('* e:{}'.format(e))
|
||||
start = datetime.strptime(e['start'], "%Y-%m-%dT%H:%M:%SZ").strftime("%Y%m%d%H%M%S +0000")
|
||||
stop = datetime.strptime(e['stop'], "%Y-%m-%dT%H:%M:%SZ").strftime("%Y%m%d%H%M%S +0000")
|
||||
# 2026-11-10T08:00:00Z -> 20261110080000 +0000, without strptime: inside
|
||||
# Kodi it fails at random ("'NoneType' object is not callable")
|
||||
start, stop = [re.sub(r'\D', '', e[k]) + ' +0000' for k in ('start', 'stop')]
|
||||
url = e.get('stream', None)
|
||||
if url:
|
||||
url = url.replace('&', '&')
|
||||
@@ -1533,8 +1560,6 @@ class Movistar(object):
|
||||
'>\n' +
|
||||
' <title>{}</title>\n'.format(html_escape(e['title'])) +
|
||||
' <sub-title>{}</sub-title>\n'.format(html_escape(e['subtitle'])))
|
||||
if 'image' in e:
|
||||
res.append(' <icon src="{}"/>\n'.format(e['image']))
|
||||
if 'description' in e:
|
||||
res.append(' <desc>{}</desc>\n'.format(html_escape(e['description'])))
|
||||
if 'credits' in e and len(e['credits']) > 0:
|
||||
@@ -1545,11 +1570,19 @@ class Movistar(object):
|
||||
elif c['type'] == 'actor':
|
||||
res.append(' <actor>{}</actor>\n'.format(c['name']))
|
||||
res.append(' </credits>\n');
|
||||
if 'year' in e:
|
||||
res.append(' <date>{}</date>\n'.format(html_escape(e['year'])))
|
||||
if 'genre' in e:
|
||||
res.append(' <category>{}</category>\n'.format(html_escape(e['genre'])))
|
||||
if 'image' in e:
|
||||
res.append(' <icon src="{}"/>\n'.format(html_escape(e['image'])))
|
||||
if 'age_rating' in e:
|
||||
res.append(' <rating system="ES"><value>{}</value></rating>\n'.format(html_escape(e['age_rating'])))
|
||||
res.append('</programme>\n')
|
||||
if self.add_extra_channels:
|
||||
res.extend(kodimov.extra_channels_xmltv(self.net.session))
|
||||
res.append('</tv>\n')
|
||||
kodimov.write_if_changed(filename, ''.join(res))
|
||||
return kodimov.write_if_changed(filename, ''.join(res))
|
||||
|
||||
def save_credentials(self, username, password):
|
||||
from .b64 import encode_base64
|
||||
|
||||
+61
-9
@@ -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)
|
||||
@@ -663,11 +684,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
|
||||
@@ -752,6 +770,7 @@ def iptv(params):
|
||||
|
||||
def create_iptv_settings():
|
||||
from .iptv import save_iptv_settings
|
||||
from . import kodimov
|
||||
show_notification(addon.getLocalizedString(30319), xbmcgui.NOTIFICATION_INFO)
|
||||
output_file = 'instance-settings-91.xml' if kodi_version > 19 else 'settings.xml'
|
||||
epg_url = None
|
||||
@@ -762,11 +781,33 @@ def create_iptv_settings():
|
||||
pvr_dir = translatePath(pvr_addon.getAddonInfo('profile'))
|
||||
LOG('pvr_dir: {}'.format(pvr_dir))
|
||||
filename = os.path.join(pvr_dir, output_file)
|
||||
if kodi_version > 19:
|
||||
# Rewrite the instance that already reads this add-on's list, if any:
|
||||
# a second one would show every channel twice
|
||||
existing = kodimov.iptv_instance_files(pvr_dir, 'kodimovplus')
|
||||
if existing: filename = existing[0]
|
||||
if os.path.exists(filename):
|
||||
res = xbmcgui.Dialog().yesno(addon.getLocalizedString(30322), addon.getLocalizedString(30323))
|
||||
if res == False: return
|
||||
save_iptv_settings(filename, 'KodiMov+', 'kodimovplus', epg_url)
|
||||
# Write the channel list and the programme guide first: if IPTV Simple
|
||||
# starts before epg.xml exists, Kodi stores an empty guide for every
|
||||
# channel and doesn't ask again for hours.
|
||||
export_epg_now()
|
||||
if not os.path.exists(os.path.join(profile_dir, 'epg.xml')):
|
||||
show_notification(addon.getLocalizedString(30509)) # Log in first
|
||||
return
|
||||
save_iptv_settings(filename, 'KodiMov+', 'kodimovplus', epg_url)
|
||||
# IPTV Simple now reads epg.xml, so the service must keep it up to date.
|
||||
addon.setSettingBool('auto_export_epg', True)
|
||||
# Restart IPTV Simple so it loads the new instance and channel list.
|
||||
kodimov.restart_iptv_simple()
|
||||
# Show Movistar's own channel numbers instead of 1, 2, 3... Kodi only
|
||||
# accepts it once IPTV Simple is back; the service keeps trying otherwise.
|
||||
numbers_set = os.path.join(profile_dir, 'numbers-set')
|
||||
if os.path.exists(numbers_set): os.remove(numbers_set)
|
||||
for _ in range(5):
|
||||
if kodimov.ensure_channel_numbers(numbers_set): break
|
||||
xbmc.sleep(2000)
|
||||
except:
|
||||
show_notification(addon.getLocalizedString(30324))
|
||||
|
||||
@@ -776,7 +817,7 @@ def export_channels_now():
|
||||
if m.export_channels_to_m3u8(channels_filename, addon.getSettingBool('only_subscribed')):
|
||||
LOG('export_channels_now: channel list changed')
|
||||
|
||||
def export_epg_now(quiet=False):
|
||||
def export_epg_now(quiet=False, reload_guide=False):
|
||||
# quiet: background refresh from the service, no pop-ups while watching TV
|
||||
def notify(message):
|
||||
if not quiet: show_notification(message, xbmcgui.NOTIFICATION_INFO)
|
||||
@@ -793,7 +834,14 @@ def export_epg_now(quiet=False):
|
||||
|
||||
if True: #not addon.getSettingBool('use_external_epg'):
|
||||
notify(addon.getLocalizedString(30311))
|
||||
m.export_epg_to_xml(epg_filename, addon.getSettingInt('export_days'), report_progress, only_subscribed)
|
||||
changed = m.export_epg_to_xml(epg_filename, addon.getSettingInt('export_days'), report_progress, only_subscribed)
|
||||
if reload_guide == 'force' or (changed and reload_guide):
|
||||
from . import kodimov
|
||||
try:
|
||||
pvr_dir = translatePath(xbmcaddon.Addon('pvr.iptvsimple').getAddonInfo('profile'))
|
||||
except RuntimeError: # IPTV Simple not installed or disabled
|
||||
pvr_dir = None
|
||||
if pvr_dir: kodimov.reload_iptv_guide(pvr_dir, 'kodimovplus')
|
||||
|
||||
notify(addon.getLocalizedString(30313))
|
||||
|
||||
@@ -844,6 +892,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':
|
||||
@@ -889,7 +941,7 @@ def router(paramstring):
|
||||
elif params['action'] == 'create_iptv_settings':
|
||||
create_iptv_settings()
|
||||
elif params['action'] == 'export_epg_now':
|
||||
export_epg_now(quiet=params.get('quiet') == '1')
|
||||
export_epg_now(quiet=params.get('quiet') == '1', reload_guide=params.get('reload_guide'))
|
||||
elif params['action'] == 'to_wishlist':
|
||||
to_wishlist(params)
|
||||
elif 'iptv' in params['action']:
|
||||
|
||||
+20
-3
@@ -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
|
||||
@@ -269,6 +274,10 @@ PORT = 57031
|
||||
|
||||
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
# KodiMov+: must be set before the server binds (setting it on the
|
||||
# instance afterwards did nothing): connections of the previous proxy
|
||||
# left in TIME_WAIT would otherwise keep the port taken
|
||||
allow_reuse_address = True
|
||||
|
||||
class Proxy(object):
|
||||
started = False
|
||||
@@ -276,8 +285,8 @@ class Proxy(object):
|
||||
def check_port(self, port=0, default=False):
|
||||
try:
|
||||
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind((HOST, port))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((HOST, port))
|
||||
return s.getsockname()[1]
|
||||
except:
|
||||
return default
|
||||
@@ -287,6 +296,14 @@ class Proxy(object):
|
||||
return
|
||||
|
||||
port = self.check_port(PORT)
|
||||
# KodiMov+: when the add-on updates, the previous service may still
|
||||
# hold the port for a few seconds. Wait for it: a stream that is
|
||||
# playing keeps asking this port for its manifest, and dies if the
|
||||
# proxy moves to another one.
|
||||
for _ in range(20):
|
||||
if port: break
|
||||
time.sleep(0.5)
|
||||
port = self.check_port(PORT)
|
||||
if not port:
|
||||
port = self.check_port(0)
|
||||
LOG('port: {}'.format(port))
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<setting label="30020" type="bool" id="channels_with_epg" default="false"/>
|
||||
<setting label="30021" type="bool" id="add_extra_info" 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 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"/>
|
||||
@@ -62,7 +63,8 @@
|
||||
-->
|
||||
|
||||
<setting label="30508" type="bool" id="export_channels" default="true"/>
|
||||
<setting label="30305" type="bool" id="export_epg" default="true"/>
|
||||
<!-- "auto_export_epg", not "export_epg": kodimov.1 shipped "export_epg" off and Kodi kept that value -->
|
||||
<setting label="30305" type="bool" id="auto_export_epg" default="true"/>
|
||||
<setting label="30315" type="number" id="export_days" default="2" enable="eq(-1,true)"/>
|
||||
<setting type="sep"/>
|
||||
<setting label="30312" type="action" id="export_epg_now" action="RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now)" option="close"/>
|
||||
|
||||
+57
-26
@@ -10,27 +10,14 @@ import xbmcgui
|
||||
from resources.lib.proxy import Proxy
|
||||
from resources.lib.addon import *
|
||||
from resources.lib.log import LOG
|
||||
from resources.lib import kodimov
|
||||
|
||||
def export_epg():
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def needs_update(filename):
|
||||
if os.path.exists(filename):
|
||||
file_modification_time = datetime.fromtimestamp(os.path.getmtime(filename))
|
||||
return datetime.now() - file_modification_time > timedelta(hours=6)
|
||||
return True
|
||||
|
||||
channels_filename = os.path.join(profile_dir, 'channels.m3u8')
|
||||
epg_filename = os.path.join(profile_dir, 'epg.xml')
|
||||
LOG('channels filename: {}'.format(channels_filename))
|
||||
LOG('epg filename: {}'.format(epg_filename))
|
||||
|
||||
channels_needs_update = needs_update(channels_filename)
|
||||
epg_needs_update = needs_update(epg_filename)
|
||||
LOG('channels_needs_update: {} epg_needs_update: {}'.format(channels_needs_update, epg_needs_update))
|
||||
|
||||
if channels_needs_update or epg_needs_update:
|
||||
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now)')
|
||||
def file_age(filename):
|
||||
"""Seconds since the file was last written (or found unchanged)."""
|
||||
try:
|
||||
return time.time() - os.path.getmtime(filename)
|
||||
except OSError:
|
||||
return float('inf')
|
||||
|
||||
if __name__ == '__main__':
|
||||
LOG('Service started')
|
||||
@@ -39,17 +26,61 @@ if __name__ == '__main__':
|
||||
LOG('proxy_address: {}'.format(proxy.proxy_address))
|
||||
|
||||
# Refreshes the TV guide channel list (new channels, new numbers) and the
|
||||
# programme guide a minute after Kodi starts and then every 6 hours. Opening the add-on also renews the 10-day Movistar login.
|
||||
next_export = time.time() + 60
|
||||
# programme guide a minute after Kodi starts and then every 6 hours; a
|
||||
# refresh that failed is tried again half an hour later. Opening the add-on
|
||||
# also renews the 10-day Movistar login.
|
||||
started = time.time()
|
||||
next_check = started + 60
|
||||
max_age = 0 # always refresh once after Kodi starts
|
||||
new_api_retries = 6
|
||||
try:
|
||||
pvr_dir = translatePath(xbmcaddon.Addon('pvr.iptvsimple').getAddonInfo('profile'))
|
||||
restart_iptv = kodimov.remove_extra_instances(pvr_dir, 'kodimovplus')
|
||||
restart_iptv = kodimov.set_iptv_refresh(pvr_dir, 'kodimovplus') or restart_iptv
|
||||
except RuntimeError: # IPTV Simple not installed or disabled
|
||||
pvr_dir = None
|
||||
restart_iptv = False
|
||||
# A changed instance file needs IPTV Simple restarted. The guide reload
|
||||
# after start (below) restarts it anyway, so that one is left to do it;
|
||||
# this is only the fallback, 10 minutes later, if it didn't happen.
|
||||
restart_after = started + 10 * 60
|
||||
monitor = xbmc.Monitor()
|
||||
while not monitor.abortRequested():
|
||||
if monitor.waitForAbort(1):
|
||||
break
|
||||
if time.time() >= next_export:
|
||||
next_export = time.time() + 6 * 3600
|
||||
if addon.getSettingBool('export_epg'): # channels + programme guide
|
||||
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now&quiet=1)')
|
||||
if restart_iptv and kodimov.last_iptv_restart() > started:
|
||||
restart_iptv = False
|
||||
if restart_iptv and time.time() >= restart_after and kodimov.is_idle():
|
||||
restart_iptv = False
|
||||
kodimov.restart_iptv_simple()
|
||||
if time.time() >= next_check:
|
||||
next_check = time.time() + 30 * 60
|
||||
exporting = False
|
||||
# Movistar's channel numbers in Kodi's TV section, also when another
|
||||
# add-on (Radios de Vigo) adds its own IPTV Simple instance
|
||||
if pvr_dir and kodimov.has_iptv_instance(pvr_dir, 'kodimovplus'):
|
||||
kodimov.ensure_channel_numbers(os.path.join(profile_dir, 'numbers-set'))
|
||||
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 restart_iptv: reload_guide = '&reload_guide=force' # also for the changed instance files
|
||||
# The new servers failed (they at times refuse everything for minutes):
|
||||
# the list lacks LALIGA, DAZN LALIGA, Liga de Campeones. Try again in
|
||||
# half an hour, a few times, instead of in 6 hours.
|
||||
if new_api_retries and kodimov.new_api_failed(profile_dir):
|
||||
new_api_retries -= 1
|
||||
age_limit = 0
|
||||
reload_guide = reload_guide or '&reload_guide=1'
|
||||
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()
|
||||
Reference in new issue
Block a user