Compare commits
23
Commits
0a4044a676
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62bf07a2b | ||
|
|
7873b50203 | ||
|
|
69a37ff064 | ||
|
|
b3bc561c35 | ||
|
|
e72e3d4902 | ||
|
|
e16dd66f63 | ||
|
|
5df60a1fb8 | ||
|
|
9faaa482fc | ||
|
|
09d6a68f74 | ||
|
|
d74c33011b | ||
|
|
3940d1b3e6 | ||
|
|
c9f062206c | ||
|
|
c4a0a7e97d | ||
|
|
119002fbbf | ||
|
|
235f7a6cab | ||
|
|
066a203e9e | ||
|
|
ccc17b4e5c | ||
|
|
6511fa80b0 | ||
|
|
0739cca0a9 | ||
|
|
930b238204 | ||
|
|
6aab76c47d | ||
|
|
bd0853e126 | ||
|
|
4f738f61c7 |
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
VERSION = $(shell ./get_version.py)
|
VERSION = $(shell ./get_version.py)
|
||||||
OUTPUT_DIR = plugin.video.movistarplus-$(VERSION)
|
OUTPUT_DIR = plugin.video.kodimovplus-$(VERSION)
|
||||||
OUTPUT_FILE = $(OUTPUT_DIR).zip
|
OUTPUT_FILE = $(OUTPUT_DIR).zip
|
||||||
|
|
||||||
install:
|
install:
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
# KodiMov+
|
||||||
|
|
||||||
|
Fork of [Paco8's Movistar+ add-on](https://github.com/Paco8/plugin.video.movistarplus) (1.0.0) for Kodi, id `plugin.video.kodimovplus`. Installs from the xocko Kodi repository:
|
||||||
|
`https://git.anosanube.ovh/xocko/kodi-repo/raw/branch/main/repository.xocko.zip`
|
||||||
|
|
||||||
|
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).
|
||||||
|
- **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. 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<addon id="plugin.video.movistarplus"
|
<addon id="plugin.video.kodimovplus"
|
||||||
name="Movistar+"
|
name="KodiMov+"
|
||||||
version="0.11.0"
|
version="1.0.0+kodimov.14"
|
||||||
provider-name="Paco8">
|
provider-name="Paco8, xocko">
|
||||||
<requires>
|
<requires>
|
||||||
<!--- <import addon="xbmc.python" version="2.25.0"/> -->
|
<!--- <import addon="xbmc.python" version="2.25.0"/> -->
|
||||||
<import addon="script.module.requests"/>
|
<import addon="script.module.requests"/>
|
||||||
@@ -17,16 +17,30 @@
|
|||||||
<extension point="xbmc.service" library="service.py" />
|
<extension point="xbmc.service" library="service.py" />
|
||||||
|
|
||||||
<extension point="xbmc.addon.metadata">
|
<extension point="xbmc.addon.metadata">
|
||||||
<summary lang="en_GB">Movistar+</summary>
|
<summary lang="en_GB">Movistar+ for Kodi, with automatic login from the Movistar home network</summary>
|
||||||
<description lang="en_GB">Movistar+</description>
|
<summary lang="es_ES">Movistar+ para Kodi, con inicio de sesión automático desde la red de Movistar</summary>
|
||||||
<description lang="en_GB">Watch live channels, recordings and video on demand content from Movistar+ in Kodi. Requires a subscription.</description>
|
<description lang="en_GB">Fork of Paco8's Movistar+ add-on (plugin.video.movistarplus). Logs in by itself from the Movistar home network, keeps the channel list and numbers up to date, plays the LALIGA, DAZN LALIGA and Liga de Campeones channels through Movistar's new servers (from the Movistar home network), hides the channels it can't play, adds free Galician channels (TVG2, TeleVigo) and writes a programme guide for every channel. Requires a Movistar+ subscription.</description>
|
||||||
<description lang="es_ES">Vea canales en directo, grabaciones, U7D y TV a la carta de Movistar+ en Kodi. Se necesita estar abonado.</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="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>
|
<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>
|
||||||
<platform>all</platform>
|
<platform>all</platform>
|
||||||
<license>LGPL-2.1-or-later</license>
|
<license>LGPL-2.1-or-later</license>
|
||||||
<website></website>
|
<website>https://git.anosanube.ovh/xocko/kodimovplus</website>
|
||||||
<source></source>
|
<source>https://git.anosanube.ovh/xocko/kodimovplus</source>
|
||||||
<assets>
|
<assets>
|
||||||
<icon>icon.png</icon>
|
<icon>icon.png</icon>
|
||||||
<fanart>resources/fanart.png</fanart>
|
<fanart>resources/fanart.png</fanart>
|
||||||
|
|||||||
Binary file not shown.
@@ -621,3 +621,47 @@ msgstr ""
|
|||||||
msgctxt "#30451"
|
msgctxt "#30451"
|
||||||
msgid "If you find this addon useful you can buy the developer a coffee:"
|
msgid "If you find this addon useful you can buy the developer a coffee:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30501"
|
||||||
|
msgid "Log in from the Movistar home network"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30502"
|
||||||
|
msgid "Movistar account number (e.g. 12345678901234-TEF)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30503"
|
||||||
|
msgid "Logged in from the Movistar network"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30504"
|
||||||
|
msgid "Network login failed: {}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30505"
|
||||||
|
msgid "Log in automatically from the Movistar home network"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30506"
|
||||||
|
msgid "Hide channels this add-on can't play"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30507"
|
||||||
|
msgid "Add free Galician channels (TVG2, TeleVigo) to the TV guide"
|
||||||
|
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 ""
|
||||||
@@ -585,6 +585,50 @@ msgctxt "#30451"
|
|||||||
msgid "If you find this addon useful you can buy the developer a coffee:"
|
msgid "If you find this addon useful you can buy the developer a coffee:"
|
||||||
msgstr "Si este addon es útil para ti puedes invitar al programador a un café:"
|
msgstr "Si este addon es útil para ti puedes invitar al programador a un café:"
|
||||||
|
|
||||||
|
msgctxt "#30501"
|
||||||
|
msgid "Log in from the Movistar home network"
|
||||||
|
msgstr "Iniciar sesión desde la red de Movistar"
|
||||||
|
|
||||||
|
msgctxt "#30502"
|
||||||
|
msgid "Movistar account number (e.g. 12345678901234-TEF)"
|
||||||
|
msgstr "Número de cuenta de Movistar (p. ej. 12345678901234-TEF)"
|
||||||
|
|
||||||
|
msgctxt "#30503"
|
||||||
|
msgid "Logged in from the Movistar network"
|
||||||
|
msgstr "Sesión iniciada desde la red de Movistar"
|
||||||
|
|
||||||
|
msgctxt "#30504"
|
||||||
|
msgid "Network login failed: {}"
|
||||||
|
msgstr "Falló el inicio de sesión por red: {}"
|
||||||
|
|
||||||
|
msgctxt "#30505"
|
||||||
|
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"
|
||||||
|
msgstr "Ocultar los canales que no se pueden reproducir"
|
||||||
|
|
||||||
|
msgctxt "#30507"
|
||||||
|
msgid "Add free Galician channels (TVG2, TeleVigo) to the TV guide"
|
||||||
|
msgstr "Añadir canales gallegos gratuitos (TVG2, TeleVigo) a la guía"
|
||||||
|
|
||||||
|
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"
|
#~ msgctxt "#30040"
|
||||||
#~ msgid "Use only for these languages (e.g.: es en pt):"
|
#~ msgid "Use only for these languages (e.g.: es en pt):"
|
||||||
#~ msgstr "Usar solo para estos idiomas (ej: es en pt):"
|
#~ msgstr "Usar solo para estos idiomas (ej: es en pt):"
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -32,5 +32,6 @@ endpoints = {
|
|||||||
"setUpStream": "https://alkasvaspub.imagenio.telefonica.net/asvas/ccs/{PID}/{deviceCode}/{PLAYREADYID}/Session",
|
"setUpStream": "https://alkasvaspub.imagenio.telefonica.net/asvas/ccs/{PID}/{deviceCode}/{PLAYREADYID}/Session",
|
||||||
"tearDownStream": "https://alkasvaspub.imagenio.telefonica.net/asvas/ccs/{PID}/{deviceCode}/{PLAYREADYID}/Session/{SessionID}",
|
"tearDownStream": "https://alkasvaspub.imagenio.telefonica.net/asvas/ccs/{PID}/{deviceCode}/{PLAYREADYID}/Session/{SessionID}",
|
||||||
"token": "https://auth.dof6.com/auth/oauth2/token?deviceClass={deviceType}",
|
"token": "https://auth.dof6.com/auth/oauth2/token?deviceClass={deviceType}",
|
||||||
"ultimasreproducciones": "https://perso.dof6.com/movistarplus/{deviceType}/users/{DIGITALPLUSUSERIDC}/viewings?profile={PROFILE}&container=trackedseries&mediaType=FOTOH&idsOnly={idsOnly}&accountNumber={ACCOUNTNUMBER}&version=8&start={start}&end={end}&mdrm={mdrm}&tlsstream=true&demarcation={demarcation}"
|
"ultimasreproducciones": "https://perso.dof6.com/movistarplus/{deviceType}/users/{DIGITALPLUSUSERIDC}/viewings?profile={PROFILE}&container=trackedseries&mediaType=FOTOH&idsOnly={idsOnly}&accountNumber={ACCOUNTNUMBER}&version=8&start={start}&end={end}&mdrm={mdrm}&tlsstream=true&demarcation={demarcation}",
|
||||||
|
"watermarks": "https://ottcache.dof6.com/movistarplus/{deviceType}/channels/{uid}/watermarks?profile={profile}&mdrm=true"
|
||||||
}
|
}
|
||||||
@@ -15,8 +15,8 @@ def save_iptv_settings(filename, name, addon_id, epg_url=None):
|
|||||||
<setting id="m3uCache" default="true">true</setting>
|
<setting id="m3uCache" default="true">true</setting>
|
||||||
<setting id="startNum" default="true">1</setting>
|
<setting id="startNum" default="true">1</setting>
|
||||||
<setting id="numberByOrder" default="true">false</setting>
|
<setting id="numberByOrder" default="true">false</setting>
|
||||||
<setting id="m3uRefreshMode" default="true">0</setting>
|
<setting id="m3uRefreshMode">1</setting>
|
||||||
<setting id="m3uRefreshIntervalMins" default="true">60</setting>
|
<setting id="m3uRefreshIntervalMins">60</setting>
|
||||||
<setting id="m3uRefreshHour" default="true">4</setting>
|
<setting id="m3uRefreshHour" default="true">4</setting>
|
||||||
<setting id="connectioncheckinterval" default="true">10</setting>
|
<setting id="connectioncheckinterval" default="true">10</setting>
|
||||||
<setting id="connectionchecktimeout" default="true">20</setting>
|
<setting id="connectionchecktimeout" default="true">20</setting>
|
||||||
@@ -82,7 +82,7 @@ def save_iptv_settings(filename, name, addon_id, epg_url=None):
|
|||||||
|
|
||||||
content += '''
|
content += '''
|
||||||
<setting id="catchupEnabled">true</setting>
|
<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>'''
|
<setting id="allChannelsCatchupMode">2</setting>'''
|
||||||
|
|
||||||
content += '''
|
content += '''
|
||||||
|
|||||||
@@ -0,0 +1,701 @@
|
|||||||
|
# encoding: utf-8
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
#
|
||||||
|
# KodiMov+ additions to the Movistar+ add-on:
|
||||||
|
# - 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
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .log import LOG
|
||||||
|
|
||||||
|
ORIGINAL_ADDON = 'plugin.video.movistarplus'
|
||||||
|
CLASSIC_ISSUER = 'http://www.movistarplus.es'
|
||||||
|
|
||||||
|
# Movistar marks the football and premium sports channels with QMWatermark.
|
||||||
|
# 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
|
||||||
|
'DAZBA', 'DAZB2', 'DAZB3', # DAZN Baloncesto 1-3
|
||||||
|
}
|
||||||
|
|
||||||
|
# Free channels that Movistar+ doesn't carry. 'chno' must not clash with a
|
||||||
|
# Movistar dial number.
|
||||||
|
EXTRA_CHANNELS = [
|
||||||
|
{'id': 'TVG2', 'name': 'TVG2', 'chno': 10, 'group': 'Galicia', 'epg_id': 'TVG2',
|
||||||
|
'logo': 'https://i.imgur.com/yFy6gxg.png',
|
||||||
|
'url': 'https://crtvg-tvg2.flumotion.cloud/playlist.m3u8'},
|
||||||
|
{'id': 'TeleVigo', 'name': 'TeleVigo', 'chno': 15, 'group': 'Galicia', 'epg_id': 'TELEVIGO',
|
||||||
|
'logo': 'https://i.imgur.com/4iVmlFa.png',
|
||||||
|
'url': 'https://stream-us-east-1.getpublica.com/playlist.m3u8?network_id=12687'},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Programme listings for the extra channels (Movistar's guide covers the rest).
|
||||||
|
EXTRA_EPG_URL = 'https://raw.githubusercontent.com/davidmuma/EPG_dobleM/master/guiafanart_color.xml.gz'
|
||||||
|
|
||||||
|
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
|
||||||
|
Movistar account and no password is needed."""
|
||||||
|
config_directory = config_directory.rstrip('/\\')
|
||||||
|
if os.path.exists(os.path.join(config_directory, 'account.txt')): return False
|
||||||
|
if any(d.startswith('account_') for d in os.listdir(config_directory)): return False
|
||||||
|
source = os.path.join(os.path.dirname(config_directory), ORIGINAL_ADDON)
|
||||||
|
if not os.path.isdir(source): return False
|
||||||
|
copied = False
|
||||||
|
for d in os.listdir(source):
|
||||||
|
src = os.path.join(source, d)
|
||||||
|
if d.startswith('account_') and os.path.isdir(src):
|
||||||
|
shutil.copytree(src, os.path.join(config_directory, d), ignore=shutil.ignore_patterns('cache'))
|
||||||
|
copied = True
|
||||||
|
if copied and os.path.exists(os.path.join(source, 'account.txt')):
|
||||||
|
shutil.copy(os.path.join(source, 'account.txt'), config_directory)
|
||||||
|
LOG('migrate_from_original: {}'.format('copied login from ' + ORIGINAL_ADDON if copied else 'nothing to copy'))
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(cache, filename):
|
||||||
|
content = cache.load_file(filename)
|
||||||
|
try:
|
||||||
|
return json.loads(content) if content else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_account_number(m, setting=''):
|
||||||
|
data = _read_json(m.cache, 'account.json')
|
||||||
|
try:
|
||||||
|
return data['ofertas'][0]['accountNumber']
|
||||||
|
except (TypeError, KeyError, IndexError):
|
||||||
|
pass
|
||||||
|
data = _read_json(m.cache, 'tokens.json')
|
||||||
|
if isinstance(data, dict) and data.get('accountNumber'):
|
||||||
|
return data['accountNumber']
|
||||||
|
return (setting or '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
headers = m.net.headers.copy()
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
url = AUTOLOGIN_URL.format(device=device_id, ts=int(time.time() * 1000))
|
||||||
|
r = m.net.session.get(url, headers=headers, allow_redirects=False, timeout=(4, 10))
|
||||||
|
location = r.headers.get('Location', '')
|
||||||
|
if r.status_code in (301, 302, 303, 307, 308) and location:
|
||||||
|
if not urlsplit(location).netloc.endswith('.telefonicaservices.com'):
|
||||||
|
raise ValueError('unexpected redirect')
|
||||||
|
h2 = headers.copy()
|
||||||
|
h2['Origin'] = 'null'
|
||||||
|
r = m.net.session.get(location, headers=h2, allow_redirects=False, timeout=(4, 10))
|
||||||
|
token = r.json().get('access_token')
|
||||||
|
if not token: raise ValueError('autologin refused')
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _soter(m, method, url, token, body=None, attempts=6):
|
||||||
|
"""Signed call to the new servers. They often answer with an HTML error
|
||||||
|
page (sometimes a 401) for no reason, so retry those."""
|
||||||
|
from .signature import get_auth
|
||||||
|
r = None
|
||||||
|
for attempt in range(attempts):
|
||||||
|
h = m.net.headers.copy()
|
||||||
|
h['Content-Type'] = 'application/json'
|
||||||
|
h['Accept'] = 'application/json, text/javascript, */*; q=0.01'
|
||||||
|
h['Authorization'] = get_auth(method, url, token)
|
||||||
|
try:
|
||||||
|
r = m.net.session.request(method.upper(), url, headers=h, timeout=(4, 15),
|
||||||
|
data=json.dumps(body) if body is not None else None)
|
||||||
|
text = r.content.decode('utf-8', errors='replace')
|
||||||
|
if not text.lstrip().startswith('<'):
|
||||||
|
return r.status_code, text
|
||||||
|
except Exception as e:
|
||||||
|
LOG('soter {} failed: {}'.format(url.split('?')[0], e))
|
||||||
|
LOG('soter {}: attempt {} got an error page ({})'.format(url.split('?')[0], attempt + 1, getattr(r, 'status_code', '?')))
|
||||||
|
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'
|
||||||
|
|
||||||
|
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)
|
||||||
|
# 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
|
||||||
|
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():
|
||||||
|
"""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))
|
||||||
|
for c in EXTRA_CHANNELS]
|
||||||
|
|
||||||
|
|
||||||
|
def write_if_changed(filename, content):
|
||||||
|
try:
|
||||||
|
with io.open(filename, 'r', encoding='utf-8', newline='') as f:
|
||||||
|
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'
|
||||||
|
with io.open(tmp, 'w', encoding='utf-8', newline='') as f:
|
||||||
|
f.write(content)
|
||||||
|
os.replace(tmp, filename) if hasattr(os, 'replace') else shutil.move(tmp, filename)
|
||||||
|
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."""
|
||||||
|
import gzip
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
wanted = dict((c['epg_id'], c) for c in EXTRA_CHANNELS if c.get('epg_id'))
|
||||||
|
out = []
|
||||||
|
for c in EXTRA_CHANNELS:
|
||||||
|
out.append('<channel id="{id}">\n <display-name>{name}</display-name>\n <icon src="{logo}"/>\n</channel>\n'.format(**c))
|
||||||
|
try:
|
||||||
|
r = session.get(EXTRA_EPG_URL, timeout=(10, 120))
|
||||||
|
r.raise_for_status()
|
||||||
|
found = 0
|
||||||
|
for _, el in ET.iterparse(gzip.GzipFile(fileobj=io.BytesIO(r.content))):
|
||||||
|
if el.tag == 'programme':
|
||||||
|
c = wanted.get(el.get('channel'))
|
||||||
|
if c:
|
||||||
|
el.set('channel', c['id'])
|
||||||
|
for icon in el.findall('icon'): el.remove(icon) # large fan art
|
||||||
|
out.append(ET.tostring(el, encoding='unicode') + '\n')
|
||||||
|
found += 1
|
||||||
|
el.clear()
|
||||||
|
elif el.tag == 'channel':
|
||||||
|
el.clear()
|
||||||
|
LOG('extra_channels_xmltv: {} programmes'.format(found))
|
||||||
|
except Exception as e:
|
||||||
|
LOG('extra_channels_xmltv: failed: {}'.format(e))
|
||||||
|
return out
|
||||||
+152
-21
@@ -22,6 +22,7 @@ from .network import Network
|
|||||||
from .cache import Cache
|
from .cache import Cache
|
||||||
from .timeconv import *
|
from .timeconv import *
|
||||||
from .useragent import useragent
|
from .useragent import useragent
|
||||||
|
from . import kodimov
|
||||||
|
|
||||||
class Movistar(object):
|
class Movistar(object):
|
||||||
account = {'username': '', 'password': '',
|
account = {'username': '', 'password': '',
|
||||||
@@ -37,6 +38,9 @@ class Movistar(object):
|
|||||||
'demarcation': 0}
|
'demarcation': 0}
|
||||||
|
|
||||||
add_extra_info = True
|
add_extra_info = True
|
||||||
|
hide_unplayable = True
|
||||||
|
add_extra_channels = True
|
||||||
|
add_web_channels = True
|
||||||
#dplayer = 'webplayer'
|
#dplayer = 'webplayer'
|
||||||
#device_code = 'WP_OTT'
|
#device_code = 'WP_OTT'
|
||||||
#manufacturer = 'Firefox'
|
#manufacturer = 'Firefox'
|
||||||
@@ -45,9 +49,14 @@ class Movistar(object):
|
|||||||
manufacturer = 'LG'
|
manufacturer = 'LG'
|
||||||
account_dir = 'account_1'
|
account_dir = 'account_1'
|
||||||
|
|
||||||
def __init__(self, config_directory, reuse_devices=False):
|
def __init__(self, config_directory, reuse_devices=False, network_login=False, account_number=''):
|
||||||
self.logged = False
|
self.logged = False
|
||||||
self.expired_access_token = False
|
self.expired_access_token = False
|
||||||
|
self.network_login_result = None
|
||||||
|
try:
|
||||||
|
kodimov.migrate_from_original(config_directory)
|
||||||
|
except Exception as e:
|
||||||
|
LOG('migrate_from_original failed: {}'.format(e))
|
||||||
|
|
||||||
# Network
|
# Network
|
||||||
headers = {
|
headers = {
|
||||||
@@ -111,6 +120,9 @@ class Movistar(object):
|
|||||||
#LOG('access_token: {}'.format(self.account['access_token']))
|
#LOG('access_token: {}'.format(self.account['access_token']))
|
||||||
LOG('expired_access_token: {}'.format(self.expired_access_token))
|
LOG('expired_access_token: {}'.format(self.expired_access_token))
|
||||||
|
|
||||||
|
if not self.account['access_token'] and network_login:
|
||||||
|
self.try_network_login(account_number)
|
||||||
|
|
||||||
if not self.account['access_token']: return
|
if not self.account['access_token']: return
|
||||||
|
|
||||||
# Account
|
# Account
|
||||||
@@ -209,6 +221,21 @@ class Movistar(object):
|
|||||||
data = self.cache.load_file('searchs.json')
|
data = self.cache.load_file('searchs.json')
|
||||||
self.search_list = json.loads(data) if data else []
|
self.search_list = json.loads(data) if data else []
|
||||||
|
|
||||||
|
def try_network_login(self, account_number='', force=False):
|
||||||
|
# At most one attempt every 10 minutes, unless asked from the menu.
|
||||||
|
stamp = self.cache.config_directory + 'network_login.stamp'
|
||||||
|
if not force and os.path.exists(stamp) and time.time() - os.path.getmtime(stamp) < 600:
|
||||||
|
return False
|
||||||
|
self.cache.save_file('network_login.stamp', str(int(time.time())))
|
||||||
|
success, reason = kodimov.network_login(self, kodimov.find_account_number(self, account_number))
|
||||||
|
LOG('network login: {} {}'.format(success, reason))
|
||||||
|
self.network_login_result = (success, reason)
|
||||||
|
if success:
|
||||||
|
self.account['access_token'] = self.cache.load_file('access_token.conf')
|
||||||
|
self.expired_access_token = False
|
||||||
|
self.cache.remove_file('tokens.json')
|
||||||
|
return success
|
||||||
|
|
||||||
def get_token(self):
|
def get_token(self):
|
||||||
data = {"accountNumber": self.account['id'],
|
data = {"accountNumber": self.account['id'],
|
||||||
"userProfile": self.account['profile_id'],
|
"userProfile": self.account['profile_id'],
|
||||||
@@ -479,7 +506,9 @@ class Movistar(object):
|
|||||||
|
|
||||||
def load_epg_data(self, date_str, duration=2, channels=''):
|
def load_epg_data(self, date_str, duration=2, channels=''):
|
||||||
demarcation = self.account['demarcation']
|
demarcation = self.account['demarcation']
|
||||||
url = self.endpoints['rejilla'].format(deviceType='webplayer', profile=self.account['platform'], UTCDATETIME=date_str, DURATION=duration, CHANNELS=channels, NETWORK='movistarplus', mdrm='true', demarcation=demarcation)
|
#dplayer = 'webplayer'
|
||||||
|
dplayer = self.dplayer
|
||||||
|
url = self.endpoints['rejilla'].format(deviceType=dplayer, profile=self.account['platform'], UTCDATETIME=date_str, DURATION=duration, CHANNELS=channels, NETWORK='movistarplus', mdrm='true', demarcation=demarcation)
|
||||||
if self.quality == 'UHD': url += '&filterQuality=UHD'
|
if self.quality == 'UHD': url += '&filterQuality=UHD'
|
||||||
#LOG(url)
|
#LOG(url)
|
||||||
data = self.net.load_data(url)
|
data = self.net.load_data(url)
|
||||||
@@ -512,7 +541,20 @@ class Movistar(object):
|
|||||||
epg = {}
|
epg = {}
|
||||||
if 'error' in data: return epg
|
if 'error' in data: return epg
|
||||||
|
|
||||||
|
# The amazon.tv guide is one flat list of programmes; webplayer's is
|
||||||
|
# grouped by channel. Group the flat one the same way.
|
||||||
|
if data and isinstance(data[0], dict):
|
||||||
|
groups = {}
|
||||||
|
for p in data:
|
||||||
|
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:
|
for ch in data:
|
||||||
|
if not ch: continue
|
||||||
id = ch[0]['Canal']['CodCadenaTv']
|
id = ch[0]['Canal']['CodCadenaTv']
|
||||||
if not id in epg: epg[id] = []
|
if not id in epg: epg[id] = []
|
||||||
for p in ch:
|
for p in ch:
|
||||||
@@ -527,6 +569,13 @@ class Movistar(object):
|
|||||||
if 'TituloHorLinea2' in p:
|
if 'TituloHorLinea2' in p:
|
||||||
pr['desc2'] = p['TituloHorLinea2']
|
pr['desc2'] = p['TituloHorLinea2']
|
||||||
pr['show_id'] = p['ShowId']
|
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:
|
if 'SerialId' in p:
|
||||||
pr['serie_id'] = p['SerialId']
|
pr['serie_id'] = p['SerialId']
|
||||||
pr['id'] = p['Id']
|
pr['id'] = p['Id']
|
||||||
@@ -628,13 +677,19 @@ class Movistar(object):
|
|||||||
if content:
|
if content:
|
||||||
data = json.loads(content)
|
data = json.loads(content)
|
||||||
else:
|
else:
|
||||||
url = self.endpoints['canales'].format(deviceType='webplayer', profile=profile, mdrm='true', demarcation=demarcation)
|
#dplayer = 'webplayer'
|
||||||
|
dplayer = self.dplayer
|
||||||
|
url = self.endpoints['canales'].format(deviceType=dplayer, profile=profile, mdrm='true', demarcation=demarcation)
|
||||||
if self.quality == 'UHD': url += '&filterQuality=UHD'
|
if self.quality == 'UHD': url += '&filterQuality=UHD'
|
||||||
#LOG(url)
|
#LOG(url)
|
||||||
data = self.net.load_data(url)
|
data = self.net.load_data(url)
|
||||||
if not 'error' in data:
|
if not 'error' in data:
|
||||||
self.cache.save_file(cache_filename, json.dumps(data, ensure_ascii=False))
|
self.cache.save_file(cache_filename, json.dumps(data, ensure_ascii=False))
|
||||||
|
|
||||||
|
# LALIGA, DAZN LALIGA and Liga de Campeones are only in the web player's list
|
||||||
|
if self.add_web_channels and isinstance(data, list):
|
||||||
|
data = data + kodimov.web_only_channels(self, data, profile, demarcation)
|
||||||
|
|
||||||
res = []
|
res = []
|
||||||
if 'error' in data: return res
|
if 'error' in data: return res
|
||||||
|
|
||||||
@@ -661,6 +716,13 @@ class Movistar(object):
|
|||||||
t['session_request'] = '{"contentID":"'+ t['id'] +'", "streamType":"CHN"}'
|
t['session_request'] = '{"contentID":"'+ t['id'] +'", "streamType":"CHN"}'
|
||||||
t['subscribed'] = self.is_subscribed_channel(c.get('tvProducts', []))
|
t['subscribed'] = self.is_subscribed_channel(c.get('tvProducts', []))
|
||||||
t['info']['playcount'] = 1 # Set as watched
|
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':
|
||||||
|
t['watermark_url'] = l['href']
|
||||||
# epg
|
# epg
|
||||||
#if add_epg_info:
|
#if add_epg_info:
|
||||||
# program = c['Pases'][0]
|
# program = c['Pases'][0]
|
||||||
@@ -675,6 +737,11 @@ class Movistar(object):
|
|||||||
|
|
||||||
res.append(t)
|
res.append(t)
|
||||||
|
|
||||||
|
if any(t['new_api'] for t in res):
|
||||||
|
# Back in dial order (a stable sort: the rest of the list doesn't move)
|
||||||
|
res.sort(key=kodimov.dial_number)
|
||||||
|
if self.hide_unplayable:
|
||||||
|
res = kodimov.playable_channels(self, res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def get_channels_with_epg(self):
|
def get_channels_with_epg(self):
|
||||||
@@ -1035,6 +1102,25 @@ class Movistar(object):
|
|||||||
t['stream_type'] = 'u7d'
|
t['stream_type'] = 'u7d'
|
||||||
t['session_request'] = '{"contentID":' + str(t['id']) + ', "streamType":"CUTV"}'
|
t['session_request'] = '{"contentID":' + str(t['id']) + ', "streamType":"CUTV"}'
|
||||||
if 'ShowId' in video: t['show_id'] = video['ShowId']
|
if 'ShowId' in video: t['show_id'] = video['ShowId']
|
||||||
|
elif 'Pases' in d and len(d['Pases']) > 0:
|
||||||
|
video = d['Pases'][0]
|
||||||
|
t['subscribed'] = self.is_subscribed_vod(video.get('tvProducts', []))
|
||||||
|
if 'ShowId' in video: t['show_id'] = video['ShowId']
|
||||||
|
if 'Canal' in video and 'CasId' in video['Canal']:
|
||||||
|
t['cas_id'] = video['Canal']['CasId']
|
||||||
|
stype = None
|
||||||
|
if 'VODRU7D' in ed['Ficha']:
|
||||||
|
stype = 'catch-up'
|
||||||
|
t['stream_type'] = 'u7d'
|
||||||
|
t['session_request'] = '{"contentID":' + str(t['id']) + ', "streamType":"CUTV"}'
|
||||||
|
if 'UrlVideo' in video:
|
||||||
|
t['url'] = video['UrlVideo']
|
||||||
|
else:
|
||||||
|
if stype:
|
||||||
|
for l in video['links']:
|
||||||
|
if l['rel'] == stype:
|
||||||
|
t['url'] = l['href']
|
||||||
|
break
|
||||||
if 'DatosAccesoAnonimo' in d:
|
if 'DatosAccesoAnonimo' in d:
|
||||||
da = d['DatosAccesoAnonimo']
|
da = d['DatosAccesoAnonimo']
|
||||||
t['video_format'] = da.get('FormatoVideo')
|
t['video_format'] = da.get('FormatoVideo')
|
||||||
@@ -1139,7 +1225,9 @@ class Movistar(object):
|
|||||||
|
|
||||||
def get_subtitles(self, manifest_url):
|
def get_subtitles(self, manifest_url):
|
||||||
base_url = os.path.dirname(manifest_url)
|
base_url = os.path.dirname(manifest_url)
|
||||||
content = self.net.load_url(manifest_url)
|
headers = self.net.headers.copy()
|
||||||
|
headers['x-tcdn-token'] = self.get_cdntoken()
|
||||||
|
content = self.net.load_url(manifest_url, headers)
|
||||||
rx = r'<AdaptationSet id="\d+" contentType="text" mimeType="application\/ttml\+xml" lang="(.*?)">.*?<BaseURL>(.*?)<\/BaseURL>'
|
rx = r'<AdaptationSet id="\d+" contentType="text" mimeType="application\/ttml\+xml" lang="(.*?)">.*?<BaseURL>(.*?)<\/BaseURL>'
|
||||||
matches = re.findall(rx, content, flags=re.MULTILINE | re.DOTALL)
|
matches = re.findall(rx, content, flags=re.MULTILINE | re.DOTALL)
|
||||||
res = []
|
res = []
|
||||||
@@ -1158,11 +1246,13 @@ class Movistar(object):
|
|||||||
#LOG('output_dir: {}'.format(output_dir))
|
#LOG('output_dir: {}'.format(output_dir))
|
||||||
if not os.path.exists(output_dir):
|
if not os.path.exists(output_dir):
|
||||||
os.makedirs(output_dir)
|
os.makedirs(output_dir)
|
||||||
|
headers = self.net.headers.copy()
|
||||||
|
headers['x-tcdn-token'] = self.get_cdntoken()
|
||||||
res = []
|
res = []
|
||||||
for s in sublist:
|
for s in sublist:
|
||||||
filename = output_dir + os.sep + s['lang'] + '.ttml'
|
filename = output_dir + os.sep + s['lang'] + '.ttml'
|
||||||
LOG('filename: {}'.format(filename))
|
LOG('filename: {}'.format(filename))
|
||||||
content = self.net.load_url(s['url'])
|
content = self.net.load_url(s['url'], headers)
|
||||||
with io.open(filename, 'w', encoding='utf-8', newline='') as handle:
|
with io.open(filename, 'w', encoding='utf-8', newline='') as handle:
|
||||||
handle.write(content)
|
handle.write(content)
|
||||||
res.append(filename)
|
res.append(filename)
|
||||||
@@ -1309,7 +1399,8 @@ class Movistar(object):
|
|||||||
shutil.copyfile(self.cache.config_directory + 'credentials.json', filename)
|
shutil.copyfile(self.cache.config_directory + 'credentials.json', filename)
|
||||||
|
|
||||||
def delete_session_files(self):
|
def delete_session_files(self):
|
||||||
for f in ['access_token.conf', 'account.json', 'device_id.conf', 'devices.json', 'profile_id.conf', 'tokens.json', 'channels2.json', 'channels_UHD.json', 'channels_HD.json', 'epg2.json', 'epg_UHD.json', 'epg_HD.json']:
|
for f in ['access_token.conf', 'account.json', 'device_id.conf', 'devices.json', 'profile_id.conf', 'tokens.json', 'channels2.json', 'channels_UHD.json', 'channels_HD.json', 'epg2.json', 'epg_UHD.json', 'epg_HD.json', 'cdn.conf',
|
||||||
|
'new_api.json', 'new_api.stamp', 'cdn_new.conf']:
|
||||||
self.cache.remove_file(f)
|
self.cache.remove_file(f)
|
||||||
|
|
||||||
def get_profile_image_url(self, img_id):
|
def get_profile_image_url(self, img_id):
|
||||||
@@ -1343,7 +1434,8 @@ class Movistar(object):
|
|||||||
t['logo'] = c['art']['icon']
|
t['logo'] = c['art']['icon']
|
||||||
t['preset'] = c['dial']
|
t['preset'] = c['dial']
|
||||||
args = urlencode({'action': 'play', 'stype': 'tv', 'id': c['id'], 'url': c['url'], 'session_request': c['session_request']})
|
args = urlencode({'action': 'play', 'stype': 'tv', 'id': c['id'], 'url': c['url'], 'session_request': c['session_request']})
|
||||||
t['stream'] = 'plugin://plugin.video.movistarplus/?' + args
|
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']
|
if 'cas_id' in c: t['cas_id'] = c['cas_id']
|
||||||
res.append(t)
|
res.append(t)
|
||||||
return res
|
return res
|
||||||
@@ -1355,12 +1447,15 @@ class Movistar(object):
|
|||||||
if only_subscribed and not t['subscribed']: continue
|
if only_subscribed and not t['subscribed']: continue
|
||||||
url = t['stream']
|
url = t['stream']
|
||||||
if 'cas_id' in t: url += '&cas_id=' + t['cas_id']
|
if 'cas_id' in t: url += '&cas_id=' + t['cas_id']
|
||||||
item = '#EXTINF:-1 tvg-name="{name}" tvg-id="{id}" tvg-logo="{logo}" tvg-chno="{preset}" group-title="Movistar+",{name}\n{stream}\n\n'.format(
|
item = '#EXTINF:-1 tvg-name="{name}" tvg-id="{id}" tvg-logo="{logo}" tvg-chno="{preset}" {catchup} group-title="Movistar+",{name}\n{stream}\n\n'.format(
|
||||||
name=t['name'], id=t['id'], logo=t['logo'], preset=t['preset'], stream=url)
|
name=t['name'], id=t['id'], logo=t['logo'], preset=t['preset'], stream=url, catchup='catchup="default" catchup-source="{catchup-id}" catchup-days="7"')
|
||||||
items.append(item)
|
items.append((int(t['preset'] or 0), item))
|
||||||
res = '#EXTM3U\n## Movistar+\n{}'.format(''.join(items))
|
if self.add_extra_channels:
|
||||||
with io.open(filename, 'w', encoding='utf-8', newline='') as handle:
|
items.extend(kodimov.extra_channels_m3u8())
|
||||||
handle.write(res)
|
# Kodi lists channels in file order, so keep it in channel number order.
|
||||||
|
items.sort(key=lambda x: x[0])
|
||||||
|
res = '#EXTM3U\n## KodiMov+\n{}'.format(''.join(i for _, i in items))
|
||||||
|
return kodimov.write_if_changed(filename, res)
|
||||||
|
|
||||||
def export_epg(self, date=None, duration=2):
|
def export_epg(self, date=None, duration=2):
|
||||||
if sys.version_info[0] >= 3:
|
if sys.version_info[0] >= 3:
|
||||||
@@ -1374,6 +1469,7 @@ class Movistar(object):
|
|||||||
for channel in channels:
|
for channel in channels:
|
||||||
id = channel['id']
|
id = channel['id']
|
||||||
if not id in epg: continue
|
if not id in epg: continue
|
||||||
|
new_api = '&new_api=1' if channel.get('new_api') else ''
|
||||||
res[id] = []
|
res[id] = []
|
||||||
for e in epg[id]:
|
for e in epg[id]:
|
||||||
t = {}
|
t = {}
|
||||||
@@ -1381,13 +1477,16 @@ class Movistar(object):
|
|||||||
t['subtitle'] = e['desc2']
|
t['subtitle'] = e['desc2']
|
||||||
t['start'] = datetime.utcfromtimestamp(e['start']/1000).strftime('%Y-%m-%dT%H:%M:%SZ')
|
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')
|
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:
|
if True:
|
||||||
program_id = str(e['show_id'])
|
program_id = str(e['show_id'])
|
||||||
url = 'https://grmovistar.imagenio.telefonica.net/asfe/rest/tvMediaURLs?tvProgram.id='+ program_id +'&svc=startover'
|
url = 'https://grmovistar.imagenio.telefonica.net/asfe/rest/tvMediaURLs?tvProgram.id='+ program_id +'&svc=startover'
|
||||||
session_request = '{"contentID":' + program_id +',"streamType":"CUTV"}'
|
session_request = '{"contentID":' + program_id +',"streamType":"CUTV"}'
|
||||||
args = urlencode({'action': 'play', 'stype': 'u7d', 'id': program_id, 'url': url, 'session_request': session_request})
|
args = urlencode({'action': 'play', 'stype': 'u7d', 'id': program_id, 'url': url, 'session_request': session_request})
|
||||||
t['stream'] = 'plugin://plugin.video.movistarplus/?' + args
|
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args + new_api
|
||||||
|
|
||||||
if False and self.add_extra_info and id in ['HOLLYW', 'TCM', 'AMC', 'MV3', 'MV2', 'CPSER', 'FOXGE', 'TNT']:
|
if False and self.add_extra_info and id in ['HOLLYW', 'TCM', 'AMC', 'MV3', 'MV2', 'CPSER', 'FOXGE', 'TNT']:
|
||||||
i = {'id': e['id'], 'show_id': e['show_id']}
|
i = {'id': e['id'], 'show_id': e['show_id']}
|
||||||
@@ -1450,18 +1549,17 @@ class Movistar(object):
|
|||||||
if only_subscribed and not ch['subscribed']: continue
|
if only_subscribed and not ch['subscribed']: continue
|
||||||
for e in epg[ch['id']]:
|
for e in epg[ch['id']]:
|
||||||
#LOG('* e:{}'.format(e))
|
#LOG('* e:{}'.format(e))
|
||||||
start = datetime.strptime(e['start'], "%Y-%m-%dT%H:%M:%SZ").strftime("%Y%m%d%H%M%S +0000")
|
# 2026-11-10T08:00:00Z -> 20261110080000 +0000, without strptime: inside
|
||||||
stop = datetime.strptime(e['stop'], "%Y-%m-%dT%H:%M:%SZ").strftime("%Y%m%d%H%M%S +0000")
|
# 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)
|
url = e.get('stream', None)
|
||||||
if url:
|
if url:
|
||||||
url = url.replace('&', '&')
|
url = url.replace('&', '&')
|
||||||
res.append('<programme start="{}" stop="{}" channel="{}"'.format(start, stop, ch['id']) +
|
res.append('<programme start="{}" stop="{}" channel="{}"'.format(start, stop, ch['id']) +
|
||||||
#(' catchup-id="{}"'.format(url) if url else "") +
|
(' catchup-id="{}"'.format(url) if url else "") +
|
||||||
'>\n' +
|
'>\n' +
|
||||||
' <title>{}</title>\n'.format(html_escape(e['title'])) +
|
' <title>{}</title>\n'.format(html_escape(e['title'])) +
|
||||||
' <sub-title>{}</sub-title>\n'.format(html_escape(e['subtitle'])))
|
' <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:
|
if 'description' in e:
|
||||||
res.append(' <desc>{}</desc>\n'.format(html_escape(e['description'])))
|
res.append(' <desc>{}</desc>\n'.format(html_escape(e['description'])))
|
||||||
if 'credits' in e and len(e['credits']) > 0:
|
if 'credits' in e and len(e['credits']) > 0:
|
||||||
@@ -1472,10 +1570,19 @@ class Movistar(object):
|
|||||||
elif c['type'] == 'actor':
|
elif c['type'] == 'actor':
|
||||||
res.append(' <actor>{}</actor>\n'.format(c['name']))
|
res.append(' <actor>{}</actor>\n'.format(c['name']))
|
||||||
res.append(' </credits>\n');
|
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')
|
res.append('</programme>\n')
|
||||||
|
if self.add_extra_channels:
|
||||||
|
res.extend(kodimov.extra_channels_xmltv(self.net.session))
|
||||||
res.append('</tv>\n')
|
res.append('</tv>\n')
|
||||||
with io.open(filename, 'w', encoding='utf-8', newline='') as handle:
|
return kodimov.write_if_changed(filename, ''.join(res))
|
||||||
handle.write(''.join(res))
|
|
||||||
|
|
||||||
def save_credentials(self, username, password):
|
def save_credentials(self, username, password):
|
||||||
from .b64 import encode_base64
|
from .b64 import encode_base64
|
||||||
@@ -1545,6 +1652,12 @@ class Movistar(object):
|
|||||||
os.makedirs(self.config_dir + name)
|
os.makedirs(self.config_dir + name)
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
def download_manifest(self, manifest_url):
|
||||||
|
headers = self.net.headers.copy()
|
||||||
|
headers['x-tcdn-token'] = self.get_cdntoken()
|
||||||
|
content = self.net.load_url(manifest_url, headers)
|
||||||
|
return content
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_file_if_exists(filename):
|
def load_file_if_exists(filename):
|
||||||
content = None
|
content = None
|
||||||
@@ -1560,3 +1673,21 @@ class Movistar(object):
|
|||||||
content = unicode(content, 'utf-8')
|
content = unicode(content, 'utf-8')
|
||||||
with io.open(filename, 'w', encoding='utf-8') as handle:
|
with io.open(filename, 'w', encoding='utf-8') as handle:
|
||||||
handle.write(content)
|
handle.write(content)
|
||||||
|
|
||||||
|
def get_watermarks(self, uid, test=False):
|
||||||
|
if test:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
watermarks = []
|
||||||
|
start = now + timedelta(seconds=20)
|
||||||
|
for i in range(5):
|
||||||
|
stop = start + timedelta(seconds=18)
|
||||||
|
watermarks.append({
|
||||||
|
'start': start.strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
|
'stop': stop.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
})
|
||||||
|
start = start + timedelta(minutes=1)
|
||||||
|
return watermarks
|
||||||
|
else:
|
||||||
|
url = self.endpoints['watermarks'].format(deviceType='webplayer', uid=uid, profile='OTT')
|
||||||
|
data = self.net.load_data(url)
|
||||||
|
return data.get('watermarks', [])
|
||||||
+11
-10
@@ -11,18 +11,19 @@ from requests.adapters import HTTPAdapter
|
|||||||
from requests.packages.urllib3.poolmanager import PoolManager
|
from requests.packages.urllib3.poolmanager import PoolManager
|
||||||
|
|
||||||
class CustomAdapter(HTTPAdapter, object):
|
class CustomAdapter(HTTPAdapter, object):
|
||||||
if sys.version_info[0] == 3:
|
def init_poolmanager(self, connections, maxsize, block=False):
|
||||||
def init_poolmanager(self, connections, maxsize, block=False):
|
ctx = ssl.create_default_context()
|
||||||
ctx = ssl.create_default_context()
|
ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT
|
||||||
self.poolmanager = PoolManager(
|
ctx.set_ciphers('DEFAULT:!BRAINPOOL')
|
||||||
num_pools=connections,
|
self.poolmanager = PoolManager(
|
||||||
maxsize=maxsize,
|
num_pools=connections,
|
||||||
block=block,
|
maxsize=maxsize,
|
||||||
ssl_context=ctx
|
block=block,
|
||||||
)
|
ssl_context=ctx
|
||||||
|
)
|
||||||
|
|
||||||
class MySession(requests.Session, object):
|
class MySession(requests.Session, object):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(MySession, self).__init__(*args, **kwargs)
|
super(MySession, self).__init__(*args, **kwargs)
|
||||||
if sys.platform.startswith("linux") and sys.version_info[0] == 3:
|
if sys.platform.startswith("linux"):
|
||||||
self.mount('https://', CustomAdapter())
|
self.mount('https://', CustomAdapter())
|
||||||
+165
-55
@@ -8,6 +8,8 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import io
|
import io
|
||||||
import os.path
|
import os.path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .timeconv import isodate2date
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcgui
|
import xbmcgui
|
||||||
@@ -96,17 +98,16 @@ def play(params):
|
|||||||
# url = url.replace('DASH_WPC_WIDEVINE', 'DASH_TV_WIDEVINE')
|
# url = url.replace('DASH_WPC_WIDEVINE', 'DASH_TV_WIDEVINE')
|
||||||
|
|
||||||
# U7D from start and end times
|
# U7D from start and end times
|
||||||
if stype == 'tv' and all(param in params for param in ['cas_id', 'start_time', 'end_time']):
|
#if stype == 'tv' and all(param in params for param in ['cas_id', 'start_time', 'end_time']):
|
||||||
from datetime import datetime
|
# start = datetime.utcfromtimestamp(int(params['start_time']))
|
||||||
start = datetime.utcfromtimestamp(int(params['start_time']))
|
# end = datetime.utcfromtimestamp(int(params['end_time']))
|
||||||
end = datetime.utcfromtimestamp(int(params['end_time']))
|
# catchup_url = 'https://stover-wp0.cdn.telefonica.com/{cas_id}/vxfmt=dp/Manifest.mpd?device_profile=DASH_TV_WIDEVINE&start_time={start_time}&end_time={end_time}'
|
||||||
catchup_url = 'https://stover-wp0.cdn.telefonica.com/{cas_id}/vxfmt=dp/Manifest.mpd?device_profile=DASH_TV_WIDEVINE&start_time={start_time}&end_time={end_time}'
|
# url = catchup_url.format(cas_id=params['cas_id'], start_time=start.strftime('%Y-%m-%dT%H:%M:%SZ'), end_time=end.strftime('%Y-%m-%dT%H:%M:%SZ'))
|
||||||
url = catchup_url.format(cas_id=params['cas_id'], start_time=start.strftime('%Y-%m-%dT%H:%M:%SZ'), end_time=end.strftime('%Y-%m-%dT%H:%M:%SZ'))
|
# LOG('url from capchup: {}'.format(url))
|
||||||
LOG('url from capchup: {}'.format(url))
|
|
||||||
|
|
||||||
|
|
||||||
# Read the proxy address
|
# Read the proxy address
|
||||||
proxy = Movistar.load_file_if_exists(os.path.join(profile_dir, 'proxy.txt'))
|
proxy = addon.getSetting('proxy_address')
|
||||||
LOG('proxy address: {}'.format(proxy))
|
LOG('proxy address: {}'.format(proxy))
|
||||||
|
|
||||||
if addon.getSettingBool('manifest_modification') and proxy:
|
if addon.getSettingBool('manifest_modification') and proxy:
|
||||||
@@ -122,11 +123,30 @@ def play(params):
|
|||||||
#headers += '&Host=wv-ottlic-f3.imagenio.telefonica.net'
|
#headers += '&Host=wv-ottlic-f3.imagenio.telefonica.net'
|
||||||
manifest_headers = 'User-Agent=' + useragent
|
manifest_headers = 'User-Agent=' + useragent
|
||||||
|
|
||||||
if stype in ['tv', 'u7d', 'rec']:
|
if params.get('new_api') == '1':
|
||||||
cdn_token = m.cache.load('cdn.conf')
|
# 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:
|
if not cdn_token:
|
||||||
cdn_token = m.get_cdntoken()
|
cdn_token = m.get_cdntoken()
|
||||||
m.cache.save_file('cdn.conf', cdn_token)
|
if not cdn_token:
|
||||||
|
time.sleep(2)
|
||||||
|
cdn_token = m.get_cdntoken()
|
||||||
|
if cdn_token:
|
||||||
|
m.cache.save_file('cdn.conf', cdn_token)
|
||||||
|
else:
|
||||||
|
show_notification('Ha pasado algo')
|
||||||
|
return
|
||||||
#LOG('cdn_token: {}'.format(cdn_token))
|
#LOG('cdn_token: {}'.format(cdn_token))
|
||||||
manifest_headers += '&x-tcdn-token=' + cdn_token
|
manifest_headers += '&x-tcdn-token=' + cdn_token
|
||||||
|
|
||||||
@@ -147,20 +167,35 @@ def play(params):
|
|||||||
'8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0')
|
'8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0')
|
||||||
|
|
||||||
play_item = xbmcgui.ListItem(path= url)
|
play_item = xbmcgui.ListItem(path= url)
|
||||||
play_item.setProperty('inputstream.adaptive.manifest_type', 'mpd')
|
license_type = 'com.widevine.alpha'
|
||||||
#play_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
|
license_key = ''
|
||||||
|
|
||||||
if format(addon.getSetting('drm_type')) == 'Playready':
|
if format(addon.getSetting('drm_type')) == 'Playready':
|
||||||
play_item.setProperty('inputstream.adaptive.license_type', 'com.microsoft.playready')
|
license_type = 'com.microsoft.playready'
|
||||||
else:
|
|
||||||
play_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha')
|
|
||||||
|
|
||||||
if addon.getSettingBool('use_proxy_for_license') and proxy:
|
if addon.getSettingBool('use_proxy_for_license') and proxy:
|
||||||
request_id = str(int(time.time()*1000))
|
request_id = str(int(time.time()*1000))
|
||||||
license_url = '{}/license?token={}&stype={}&session_request={}&request_id={}||R{{SSM}}|'.format(proxy, quote_plus(token), stype, quote_plus(params['session_request']), request_id)
|
license_key = '{}/license?token={}&stype={}&session_request={}&request_id={}'.format(proxy, quote_plus(token), stype, quote_plus(params['session_request']), request_id)
|
||||||
LOG('license_url: {}'.format(license_url))
|
LOG('license_key: {}'.format(license_key))
|
||||||
play_item.setProperty('inputstream.adaptive.license_key', license_url)
|
|
||||||
else:
|
else:
|
||||||
play_item.setProperty('inputstream.adaptive.license_key', '{}|{}&nv-authorizations={}|R{{SSM}}|'.format(license_url, headers, token))
|
license_key = license_url
|
||||||
|
headers += '&nv-authorizations={}'.format(token)
|
||||||
|
|
||||||
|
if kodi_version > 20:
|
||||||
|
play_item.setProperty('inputstream.adaptive.drm_legacy', '{}|{}|{}'.format(license_type, license_key, headers))
|
||||||
|
else:
|
||||||
|
play_item.setProperty('inputstream.adaptive.license_type', license_type)
|
||||||
|
play_item.setProperty('inputstream.adaptive.license_key', '{}|{}|R{{SSM}}|'.format(license_key, headers))
|
||||||
|
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.stream_headers', manifest_headers)
|
||||||
play_item.setProperty('inputstream.adaptive.manifest_headers', manifest_headers)
|
play_item.setProperty('inputstream.adaptive.manifest_headers', manifest_headers)
|
||||||
@@ -206,7 +241,9 @@ def play(params):
|
|||||||
for sub in sublist:
|
for sub in sublist:
|
||||||
filename = os.path.join(subfolder, sub['lang'])
|
filename = os.path.join(subfolder, sub['lang'])
|
||||||
LOG('Converting {}'.format(sub['filename']))
|
LOG('Converting {}'.format(sub['filename']))
|
||||||
response = m.net.session.get(sub['url'], allow_redirects=True)
|
subheaders = m.net.headers.copy()
|
||||||
|
subheaders['x-tcdn-token'] = cdn_token
|
||||||
|
response = m.net.session.get(sub['url'], allow_redirects=True, headers=subheaders)
|
||||||
content = response.content
|
content = response.content
|
||||||
with io.open(filename + '.ttml', 'w', encoding='utf-8', newline='') as handle:
|
with io.open(filename + '.ttml', 'w', encoding='utf-8', newline='') as handle:
|
||||||
handle.write(content.decode('utf-8'))
|
handle.write(content.decode('utf-8'))
|
||||||
@@ -230,32 +267,39 @@ def play(params):
|
|||||||
xbmcplugin.setResolvedUrl(_handle, True, listitem=play_item)
|
xbmcplugin.setResolvedUrl(_handle, True, listitem=play_item)
|
||||||
|
|
||||||
LOG('**** session_opened: {}'.format(session_opened))
|
LOG('**** session_opened: {}'.format(session_opened))
|
||||||
is_sport_channel = (stype == 'tv' and channel_id in ['MLIGA', 'DAZNLI', 'MLIG1', 'CHAPIO', 'CHAP1', 'CHAP2', 'MLIGUH', 'CHAUHD', 'CHUHD1'])
|
watermarks = []
|
||||||
if session_opened or is_sport_channel:
|
if stype == 'tv' and 'uid' in params:
|
||||||
if is_sport_channel:
|
watermarks = m.get_watermarks(params['uid'])
|
||||||
last_time = 0 #time.time()
|
LOG('watermarks: {}'.format(watermarks))
|
||||||
window = xbmcgui.Window(12005)
|
|
||||||
label = xbmcgui.ControlLabel(0, 0, 400, 20, m.account['id'], textColor='0xFFFFFFFF', alignment=6)
|
if session_opened or watermarks:
|
||||||
|
window = xbmcgui.Window(12005)
|
||||||
|
label = xbmcgui.ControlLabel(0, 0, 400, 20, m.account['id'], textColor='0xFFFFFFFF', alignment=6)
|
||||||
from .player import MyPlayer
|
from .player import MyPlayer
|
||||||
player = MyPlayer()
|
player = MyPlayer()
|
||||||
monitor = xbmc.Monitor()
|
monitor = xbmc.Monitor()
|
||||||
while not monitor.abortRequested() and player.running:
|
while not monitor.abortRequested() and player.running:
|
||||||
monitor.waitForAbort(10)
|
monitor.waitForAbort(10)
|
||||||
#LOG('**** waiting')
|
#LOG('**** waiting')
|
||||||
if is_sport_channel and player.isPlaying():
|
if player.isPlaying() and watermarks:
|
||||||
now = time.time()
|
now = isodate2date(datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'))
|
||||||
if now - last_time >= 15*60:
|
for wm in list(watermarks):
|
||||||
last_time = now
|
start = isodate2date(wm['start'])
|
||||||
#show_notification(m.account['id'], xbmcgui.NOTIFICATION_INFO)
|
stop = isodate2date(wm['stop'])
|
||||||
w = window.getWidth()
|
if now >= start:
|
||||||
h = window.getHeight()
|
watermarks.remove(wm)
|
||||||
#LOG('window h: {} w: {}'.format(h, w))
|
if now < stop:
|
||||||
pos_x = w-label.getWidth()-60
|
duration = (stop - start).total_seconds()
|
||||||
pos_y = h-200
|
#show_notification(m.account['id'], xbmcgui.NOTIFICATION_INFO)
|
||||||
label.setPosition(pos_x, pos_y)
|
w = window.getWidth()
|
||||||
window.addControl(label)
|
h = window.getHeight()
|
||||||
time.sleep(20)
|
#LOG('window h: {} w: {}'.format(h, w))
|
||||||
window.removeControl(label)
|
pos_x = w-label.getWidth()-60
|
||||||
|
pos_y = h-200
|
||||||
|
label.setPosition(pos_x, pos_y)
|
||||||
|
window.addControl(label)
|
||||||
|
time.sleep(duration)
|
||||||
|
window.removeControl(label)
|
||||||
if session_opened:
|
if session_opened:
|
||||||
d = m.delete_session()
|
d = m.delete_session()
|
||||||
LOG('Delete session: d: {}'.format(d))
|
LOG('Delete session: d: {}'.format(d))
|
||||||
@@ -373,7 +417,9 @@ def add_videos(category, ctype, videos, ref=None, url_next=None, url_prev=None,
|
|||||||
list_item.addContextMenuItems(actions)
|
list_item.addContextMenuItems(actions)
|
||||||
|
|
||||||
url = get_url(action='play', id=t['id'], url=t['url'], session_request=t['session_request'], stype=t['stream_type'])
|
url = get_url(action='play', id=t['id'], url=t['url'], session_request=t['session_request'], stype=t['stream_type'])
|
||||||
|
if t.get('new_api'): url += '&new_api=1'
|
||||||
if 'show_id' in t: url += '&show_id={}'.format(t['show_id'])
|
if 'show_id' in t: url += '&show_id={}'.format(t['show_id'])
|
||||||
|
if 'uid' in t: url += '&uid={}'.format(t['uid'])
|
||||||
xbmcplugin.addDirectoryItem(_handle, url, list_item, False)
|
xbmcplugin.addDirectoryItem(_handle, url, list_item, False)
|
||||||
elif t['type'] == 'series':
|
elif t['type'] == 'series':
|
||||||
list_item = xbmcgui.ListItem(label = title_name)
|
list_item = xbmcgui.ListItem(label = title_name)
|
||||||
@@ -471,7 +517,6 @@ def list_profiles(params):
|
|||||||
|
|
||||||
def list_epg(params):
|
def list_epg(params):
|
||||||
LOG('list_epg: {}'.format(params))
|
LOG('list_epg: {}'.format(params))
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from .timeconv import my_strftime
|
from .timeconv import my_strftime
|
||||||
if 'id' in params:
|
if 'id' in params:
|
||||||
if not 'date' in params:
|
if not 'date' in params:
|
||||||
@@ -638,6 +683,17 @@ def login():
|
|||||||
else:
|
else:
|
||||||
show_notification(addon.getLocalizedString(30166)) # Failed
|
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')
|
||||||
|
# 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
|
||||||
|
xbmc.executebuiltin('Container.Refresh')
|
||||||
|
else:
|
||||||
|
reason = (m.network_login_result or (False, ''))[1]
|
||||||
|
show_notification(addon.getLocalizedString(30504).format(reason)) # Failed
|
||||||
|
|
||||||
def login_with_key():
|
def login_with_key():
|
||||||
filename = xbmcgui.Dialog().browseSingle(1, addon.getLocalizedString(30182), '', '.key')
|
filename = xbmcgui.Dialog().browseSingle(1, addon.getLocalizedString(30182), '', '.key')
|
||||||
if filename:
|
if filename:
|
||||||
@@ -662,6 +718,7 @@ def export_credentials():
|
|||||||
def select_account(id, name):
|
def select_account(id, name):
|
||||||
m.switch_account(id)
|
m.switch_account(id)
|
||||||
open_folder(name)
|
open_folder(name)
|
||||||
|
add_menu_option(addon.getLocalizedString(30501), get_url(action='network_login')) # Login from home network
|
||||||
add_menu_option(addon.getLocalizedString(30183), get_url(action='login')) # Login with username
|
add_menu_option(addon.getLocalizedString(30183), get_url(action='login')) # Login with username
|
||||||
if addon.getSettingBool('enable_key_login'):
|
if addon.getSettingBool('enable_key_login'):
|
||||||
add_menu_option(addon.getLocalizedString(30181), get_url(action='login_with_key')) # Login with key
|
add_menu_option(addon.getLocalizedString(30181), get_url(action='login_with_key')) # Login with key
|
||||||
@@ -713,41 +770,80 @@ def iptv(params):
|
|||||||
|
|
||||||
def create_iptv_settings():
|
def create_iptv_settings():
|
||||||
from .iptv import save_iptv_settings
|
from .iptv import save_iptv_settings
|
||||||
|
from . import kodimov
|
||||||
show_notification(addon.getLocalizedString(30319), xbmcgui.NOTIFICATION_INFO)
|
show_notification(addon.getLocalizedString(30319), xbmcgui.NOTIFICATION_INFO)
|
||||||
output_file = 'instance-settings-91.xml' if kodi_version > 19 else 'settings.xml'
|
output_file = 'instance-settings-91.xml' if kodi_version > 19 else 'settings.xml'
|
||||||
epg_url = None
|
epg_url = None
|
||||||
if addon.getSettingBool('use_external_epg'):
|
if False: #addon.getSettingBool('use_external_epg'):
|
||||||
epg_url = addon.getSetting('epg_url')
|
epg_url = addon.getSetting('epg_url')
|
||||||
try:
|
try:
|
||||||
pvr_addon = xbmcaddon.Addon('pvr.iptvsimple')
|
pvr_addon = xbmcaddon.Addon('pvr.iptvsimple')
|
||||||
pvr_dir = translatePath(pvr_addon.getAddonInfo('profile'))
|
pvr_dir = translatePath(pvr_addon.getAddonInfo('profile'))
|
||||||
LOG('pvr_dir: {}'.format(pvr_dir))
|
LOG('pvr_dir: {}'.format(pvr_dir))
|
||||||
filename = os.path.join(pvr_dir, output_file)
|
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):
|
if os.path.exists(filename):
|
||||||
res = xbmcgui.Dialog().yesno(addon.getLocalizedString(30322), addon.getLocalizedString(30323))
|
res = xbmcgui.Dialog().yesno(addon.getLocalizedString(30322), addon.getLocalizedString(30323))
|
||||||
if res == False: return
|
if res == False: return
|
||||||
save_iptv_settings(filename, 'Movistarplus', 'movistarplus', 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()
|
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:
|
except:
|
||||||
show_notification(addon.getLocalizedString(30324))
|
show_notification(addon.getLocalizedString(30324))
|
||||||
|
|
||||||
def export_epg_now():
|
def export_channels_now():
|
||||||
|
if not m.logged: return
|
||||||
|
channels_filename = os.path.join(profile_dir, 'channels.m3u8')
|
||||||
|
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, 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)
|
||||||
def report_progress(message):
|
def report_progress(message):
|
||||||
show_notification(addon.getLocalizedString(30314).format(message), xbmcgui.NOTIFICATION_INFO)
|
notify(addon.getLocalizedString(30314).format(message))
|
||||||
|
|
||||||
if not m.logged: return
|
if not m.logged: return
|
||||||
|
|
||||||
only_subscribed = addon.getSettingBool('only_subscribed')
|
only_subscribed = addon.getSettingBool('only_subscribed')
|
||||||
channels_filename = os.path.join(profile_dir, 'channels.m3u8')
|
channels_filename = os.path.join(profile_dir, 'channels.m3u8')
|
||||||
epg_filename = os.path.join(profile_dir, 'epg.xml')
|
epg_filename = os.path.join(profile_dir, 'epg.xml')
|
||||||
show_notification(addon.getLocalizedString(30310), xbmcgui.NOTIFICATION_INFO)
|
notify(addon.getLocalizedString(30310))
|
||||||
m.export_channels_to_m3u8(channels_filename, only_subscribed)
|
m.export_channels_to_m3u8(channels_filename, only_subscribed)
|
||||||
|
|
||||||
if not addon.getSettingBool('use_external_epg'):
|
if True: #not addon.getSettingBool('use_external_epg'):
|
||||||
show_notification(addon.getLocalizedString(30311), xbmcgui.NOTIFICATION_INFO)
|
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')
|
||||||
|
|
||||||
show_notification(addon.getLocalizedString(30313), xbmcgui.NOTIFICATION_INFO)
|
notify(addon.getLocalizedString(30313))
|
||||||
|
|
||||||
|
|
||||||
def to_wishlist(params):
|
def to_wishlist(params):
|
||||||
@@ -792,6 +888,14 @@ def router(paramstring):
|
|||||||
login_with_key()
|
login_with_key()
|
||||||
elif params['action'] == 'login':
|
elif params['action'] == 'login':
|
||||||
login()
|
login()
|
||||||
|
elif params['action'] == 'network_login':
|
||||||
|
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':
|
elif params['action'] == 'export_key':
|
||||||
export_key()
|
export_key()
|
||||||
elif params['action'] == 'export_credentials':
|
elif params['action'] == 'export_credentials':
|
||||||
@@ -837,7 +941,7 @@ def router(paramstring):
|
|||||||
elif params['action'] == 'create_iptv_settings':
|
elif params['action'] == 'create_iptv_settings':
|
||||||
create_iptv_settings()
|
create_iptv_settings()
|
||||||
elif params['action'] == 'export_epg_now':
|
elif params['action'] == 'export_epg_now':
|
||||||
export_epg_now()
|
export_epg_now(quiet=params.get('quiet') == '1', reload_guide=params.get('reload_guide'))
|
||||||
elif params['action'] == 'to_wishlist':
|
elif params['action'] == 'to_wishlist':
|
||||||
to_wishlist(params)
|
to_wishlist(params)
|
||||||
elif 'iptv' in params['action']:
|
elif 'iptv' in params['action']:
|
||||||
@@ -856,8 +960,10 @@ def router(paramstring):
|
|||||||
add_menu_option(addon.getLocalizedString(30112), get_url(action='search'), icon='search.png') # Search
|
add_menu_option(addon.getLocalizedString(30112), get_url(action='search'), icon='search.png') # Search
|
||||||
add_menu_option(addon.getLocalizedString(30180), get_url(action='profiles'), icon='profiles.png') # Profiles
|
add_menu_option(addon.getLocalizedString(30180), get_url(action='profiles'), icon='profiles.png') # Profiles
|
||||||
add_menu_option(addon.getLocalizedString(30108), get_url(action='devices'), icon='devices.png') # Devices
|
add_menu_option(addon.getLocalizedString(30108), get_url(action='devices'), icon='devices.png') # Devices
|
||||||
elif m.expired_access_token:
|
else:
|
||||||
show_notification(addon.getLocalizedString(30208))
|
if m.expired_access_token:
|
||||||
|
show_notification(addon.getLocalizedString(30208))
|
||||||
|
add_menu_option(addon.getLocalizedString(30501), get_url(action='network_login'), icon='account.png') # Login from home network
|
||||||
|
|
||||||
add_menu_option(addon.getLocalizedString(30160), get_url(action='accounts'), icon='account.png') # Accounts
|
add_menu_option(addon.getLocalizedString(30160), get_url(action='accounts'), icon='account.png') # Accounts
|
||||||
add_menu_option(addon.getLocalizedString(30450), get_url(action='show_donation_dialog'), icon='qr.png') # Donation
|
add_menu_option(addon.getLocalizedString(30450), get_url(action='show_donation_dialog'), icon='qr.png') # Donation
|
||||||
@@ -878,8 +984,12 @@ def run():
|
|||||||
reuse_devices = addon.getSettingBool('reuse_devices')
|
reuse_devices = addon.getSettingBool('reuse_devices')
|
||||||
LOG('profile_dir: {}'.format(profile_dir))
|
LOG('profile_dir: {}'.format(profile_dir))
|
||||||
LOG('reuse_devices: {}'.format(reuse_devices))
|
LOG('reuse_devices: {}'.format(reuse_devices))
|
||||||
m = Movistar(profile_dir, reuse_devices=reuse_devices)
|
m = Movistar(profile_dir, reuse_devices=reuse_devices,
|
||||||
|
network_login=addon.getSettingBool('network_login'),
|
||||||
|
account_number=addon.getSetting('account_number'))
|
||||||
m.add_extra_info = addon.getSettingBool('add_extra_info')
|
m.add_extra_info = addon.getSettingBool('add_extra_info')
|
||||||
|
m.hide_unplayable = addon.getSettingBool('hide_unplayable')
|
||||||
|
m.add_extra_channels = addon.getSettingBool('extra_channels')
|
||||||
if addon.getSettingBool('uhd'):
|
if addon.getSettingBool('uhd'):
|
||||||
m.quality = 'UHD'
|
m.quality = 'UHD'
|
||||||
|
|
||||||
|
|||||||
+45
-7
@@ -38,6 +38,7 @@ import json
|
|||||||
import requests
|
import requests
|
||||||
import threading
|
import threading
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
|
|
||||||
from .b64 import encode_base64
|
from .b64 import encode_base64
|
||||||
@@ -55,6 +56,10 @@ previous_tokens = []
|
|||||||
|
|
||||||
reregister_needed = False
|
reregister_needed = False
|
||||||
|
|
||||||
|
# (connect_timeout, read_timeout) in seconds for requests to upstream (CDN) servers.
|
||||||
|
MANIFEST_TIMEOUT = (2, 3)
|
||||||
|
|
||||||
|
|
||||||
def is_ascii(s):
|
def is_ascii(s):
|
||||||
try:
|
try:
|
||||||
return s.isascii()
|
return s.isascii()
|
||||||
@@ -73,7 +78,9 @@ def mvs():
|
|||||||
if not mvs_o:
|
if not mvs_o:
|
||||||
from .movistar import Movistar
|
from .movistar import Movistar
|
||||||
reuse_devices = addon.getSettingBool('reuse_devices')
|
reuse_devices = addon.getSettingBool('reuse_devices')
|
||||||
mvs_o = Movistar(profile_dir, reuse_devices)
|
mvs_o = Movistar(profile_dir, reuse_devices,
|
||||||
|
network_login=addon.getSettingBool('network_login'),
|
||||||
|
account_number=addon.getSetting('account_number'))
|
||||||
return mvs_o
|
return mvs_o
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +101,10 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
if 'x-tcdn-token' in self.headers:
|
if 'x-tcdn-token' in self.headers:
|
||||||
additional_headers['x-tcdn-token'] = self.headers['x-tcdn-token']
|
additional_headers['x-tcdn-token'] = self.headers['x-tcdn-token']
|
||||||
#LOG('additional_headers: {}'.format(additional_headers))
|
#LOG('additional_headers: {}'.format(additional_headers))
|
||||||
response = session.get(url, allow_redirects=True, headers=additional_headers)
|
t0 = time.time()
|
||||||
|
response = session.get(url, allow_redirects=True, headers=additional_headers, timeout=MANIFEST_TIMEOUT)
|
||||||
|
t1 = time.time()
|
||||||
|
LOG('manifest fetch took {:.2f}s'.format(t1 - t0))
|
||||||
LOG('headers: {}'.format(response.headers))
|
LOG('headers: {}'.format(response.headers))
|
||||||
baseurl = os.path.dirname(response.url)
|
baseurl = os.path.dirname(response.url)
|
||||||
LOG('baseurl: {}'.format(baseurl))
|
LOG('baseurl: {}'.format(baseurl))
|
||||||
@@ -129,6 +139,8 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
#LOG('content: {}'.format(content))
|
#LOG('content: {}'.format(content))
|
||||||
manifest_data = content
|
manifest_data = content
|
||||||
|
t2 = time.time()
|
||||||
|
LOG('manifest processing took {:.2f}s'.format(t2 - t1))
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
#self.send_header('Content-type', 'application/xml')
|
#self.send_header('Content-type', 'application/xml')
|
||||||
self.send_header('Content-type', 'text/plain')
|
self.send_header('Content-type', 'text/plain')
|
||||||
@@ -138,7 +150,7 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
pos = path.find('=')
|
pos = path.find('=')
|
||||||
url = path[pos+1:]
|
url = path[pos+1:]
|
||||||
LOG('subtitle url: {}'.format(url))
|
LOG('subtitle url: {}'.format(url))
|
||||||
response = session.get(url, allow_redirects=True)
|
response = session.get(url, allow_redirects=True, timeout=MANIFEST_TIMEOUT)
|
||||||
content = response.content
|
content = response.content
|
||||||
ttml.parse_ttml_from_string(content)
|
ttml.parse_ttml_from_string(content)
|
||||||
sub_data = ttml.generate_vtt()
|
sub_data = ttml.generate_vtt()
|
||||||
@@ -156,6 +168,10 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_response(404)
|
self.send_response(404)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||||
|
self.send_response(504)
|
||||||
|
self.end_headers()
|
||||||
|
LOG('Upstream timeout/connection error: {}'.format(str(e)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_response(500)
|
self.send_response(500)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -225,12 +241,17 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
LOG('license response: {}'.format(license_data))
|
LOG('license response: {}'.format(license_data))
|
||||||
d = try_load_json(license_data)
|
d = try_load_json(license_data)
|
||||||
if d and 'errorCode' in d:
|
if d and 'errorCode' in d:
|
||||||
from .gui import show_notification
|
# KodiMov+: the error is shown only when the retry below
|
||||||
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
|
# doesn't get a licence. The first request for a LALIGA /
|
||||||
|
# Liga de Campeones channel is refused with 4027 every time,
|
||||||
|
# and passes once the device is registered again (a pause
|
||||||
|
# and a plain retry don't help; tested 2026-11-13).
|
||||||
if d['errorCode'] == 4027:
|
if d['errorCode'] == 4027:
|
||||||
if not reregister_needed and addon.getSettingBool('reregister'):
|
if not reregister_needed and addon.getSettingBool('reregister'):
|
||||||
reregister_needed = True
|
reregister_needed = True
|
||||||
continue
|
continue
|
||||||
|
from .gui import show_notification
|
||||||
|
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
|
||||||
else:
|
else:
|
||||||
LOG('license response: {}'.format(encode_base64(license_data)))
|
LOG('license response: {}'.format(encode_base64(license_data)))
|
||||||
break
|
break
|
||||||
@@ -238,6 +259,10 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_response(response.status_code)
|
self.send_response(response.status_code)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(license_data)
|
self.wfile.write(license_data)
|
||||||
|
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||||
|
self.send_response(504)
|
||||||
|
self.end_headers()
|
||||||
|
LOG('Upstream timeout/connection error: {}'.format(str(e)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_response(500)
|
self.send_response(500)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -245,10 +270,14 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
HOST = '127.0.0.1'
|
HOST = '127.0.0.1'
|
||||||
PORT = 57011
|
PORT = 57031
|
||||||
|
|
||||||
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||||
daemon_threads = True
|
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):
|
class Proxy(object):
|
||||||
started = False
|
started = False
|
||||||
@@ -256,8 +285,8 @@ class Proxy(object):
|
|||||||
def check_port(self, port=0, default=False):
|
def check_port(self, port=0, default=False):
|
||||||
try:
|
try:
|
||||||
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
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.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind((HOST, port))
|
||||||
return s.getsockname()[1]
|
return s.getsockname()[1]
|
||||||
except:
|
except:
|
||||||
return default
|
return default
|
||||||
@@ -267,6 +296,14 @@ class Proxy(object):
|
|||||||
return
|
return
|
||||||
|
|
||||||
port = self.check_port(PORT)
|
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:
|
if not port:
|
||||||
port = self.check_port(0)
|
port = self.check_port(0)
|
||||||
LOG('port: {}'.format(port))
|
LOG('port: {}'.format(port))
|
||||||
@@ -276,6 +313,7 @@ class Proxy(object):
|
|||||||
self._httpd_thread = threading.Thread(target=self._server.serve_forever)
|
self._httpd_thread = threading.Thread(target=self._server.serve_forever)
|
||||||
self._httpd_thread.start()
|
self._httpd_thread.start()
|
||||||
self.proxy_address = 'http://{}:{}'.format(HOST, port)
|
self.proxy_address = 'http://{}:{}'.format(HOST, port)
|
||||||
|
addon.setSetting('proxy_address', self.proxy_address)
|
||||||
self.started = True
|
self.started = True
|
||||||
LOG("Proxy Started: {}:{}".format(HOST, port))
|
LOG("Proxy Started: {}:{}".format(HOST, port))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# encoding: utf-8
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
|
||||||
|
from __future__ import unicode_literals, absolute_import, division
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from urllib.parse import urlparse, urlsplit, parse_qs, urlencode, quote, unquote
|
||||||
|
from .b64 import encode_base64, decode_base64
|
||||||
|
from .log import LOG
|
||||||
|
|
||||||
|
def generate_nonce(length=24):
|
||||||
|
characters = string.ascii_lowercase + string.digits
|
||||||
|
nonce = ''.join(random.choice(characters) for _ in range(length))
|
||||||
|
return encode_base64(nonce)
|
||||||
|
|
||||||
|
def create_signature(method, url, token, scheme):
|
||||||
|
consumer_key = 'web_1.0.0'
|
||||||
|
consumer_secret = 'Rk1Qc2RaREJ0M1h1MkUwRWtJRmlxWGtpOVpOZFlGN3I='
|
||||||
|
|
||||||
|
nonce = generate_nonce()
|
||||||
|
#print('nonce', nonce)
|
||||||
|
|
||||||
|
timestamp = int(time.time())
|
||||||
|
|
||||||
|
parsed_url = urlsplit(url)
|
||||||
|
path = parsed_url.netloc + parsed_url.path
|
||||||
|
query = parse_qs(parsed_url.query, keep_blank_values=True)
|
||||||
|
|
||||||
|
options = { scheme: token, 'consumer_key': consumer_key, 'nonce': nonce,
|
||||||
|
'signature_method': 'HMAC-SHA1', 'version': '1.0', 'timestamp': timestamp}
|
||||||
|
for k, v in query.items():
|
||||||
|
options[k] = quote(v[0])
|
||||||
|
|
||||||
|
query_string = "&".join("{}={}".format(key, value) for key, value in sorted(options.items()))
|
||||||
|
#print(query_string)
|
||||||
|
|
||||||
|
text = '{}&{}&{}'.format(method.upper(), quote(path, safe=''), quote(query_string, safe=''))
|
||||||
|
#print(text)
|
||||||
|
|
||||||
|
key = decode_base64(consumer_secret.encode('utf-8')).encode('utf-8')
|
||||||
|
hashed = hmac.new(key, text.encode('utf-8'), hashlib.sha1).digest()
|
||||||
|
signature = encode_base64(hashed)
|
||||||
|
|
||||||
|
res = 'consumer_key="{}",nonce="{}",signature_method="HMAC-SHA1",signature="{}",timestamp="{}",version="1.0"'.format(consumer_key, nonce, signature, timestamp)
|
||||||
|
return encode_base64(res)
|
||||||
|
|
||||||
|
def get_auth(method, url, token, scheme='Bearer'):
|
||||||
|
signature = create_signature(method, url, token, scheme)
|
||||||
|
return 'OPPlus {}={},Signature={}'.format(scheme, token, signature)
|
||||||
+11
-23
@@ -6,28 +6,16 @@
|
|||||||
from __future__ import unicode_literals, absolute_import, division
|
from __future__ import unicode_literals, absolute_import, division
|
||||||
import random
|
import random
|
||||||
|
|
||||||
useragents = [
|
FIREFOX_VERSIONS = ['140.0', '141.0', '142.0']
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.3",
|
FIREFOX_PLATFORMS = [
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.3",
|
'X11; Linux x86_64; rv:{v}',
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.3",
|
'Windows NT 10.0; Win64; x64; rv:{v}',
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.",
|
'Macintosh; Intel Mac OS X 10.15; rv:{v}',
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.3",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 OPR/112.0.0.",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.",
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.",
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.3",
|
|
||||||
"Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.",
|
|
||||||
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.14",
|
|
||||||
"Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.10",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.3"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
#useragent = random.choice(useragents)
|
def firefox_ua():
|
||||||
useragent = 'Dalvik/2.1.0 (Linux; U; Android 12; 22126RN91Y Build/SP1A.210812.016)'
|
v = random.choice(FIREFOX_VERSIONS)
|
||||||
|
p = random.choice(FIREFOX_PLATFORMS).format(v=v)
|
||||||
|
return 'Mozilla/5.0 ({}) Gecko/20100101 Firefox/{}'.format(p, v)
|
||||||
|
|
||||||
|
useragent = firefox_ua()
|
||||||
+17
-6
@@ -1,18 +1,25 @@
|
|||||||
<settings>
|
<settings>
|
||||||
<category label="30001">
|
<category label="30001">
|
||||||
|
<setting id="network_login" type="bool" label="30505" default="true"/>
|
||||||
|
<setting id="account_number" type="text" label="30502" default=""/>
|
||||||
|
<setting id="hide_unplayable" type="bool" label="30506" default="true"/>
|
||||||
|
<setting id="extra_channels" type="bool" label="30507" default="true"/>
|
||||||
|
<setting type="sep"/>
|
||||||
<setting id="store_credentials" type="bool" label="30050" default="true"/>
|
<setting id="store_credentials" type="bool" label="30050" default="true"/>
|
||||||
<setting label="30017" type="bool" id="only_subscribed" default="true"/>
|
<setting label="30017" type="bool" id="only_subscribed" default="true"/>
|
||||||
<setting label="30020" type="bool" id="channels_with_epg" default="false"/>
|
<setting label="30020" type="bool" id="channels_with_epg" default="false"/>
|
||||||
<setting label="30021" type="bool" id="add_extra_info" default="false"/>
|
<setting label="30021" type="bool" id="add_extra_info" default="false"/>
|
||||||
<setting label="30033" type="bool" id="uhd" default="false"/>
|
<setting label="30033" type="bool" id="uhd" default="false"/>
|
||||||
|
<setting label="30511" type="bool" id="best_quality" default="false"/>
|
||||||
<setting label="30042" type="number" id="expdays" default="30"/>
|
<setting label="30042" type="number" id="expdays" default="30"/>
|
||||||
<setting id="use_ttml2ssa" type="bool" label="$ADDON[script.module.ttml2ssa 32202]" default="false"/>
|
<setting id="use_ttml2ssa" type="bool" label="$ADDON[script.module.ttml2ssa 32202]" default="false"/>
|
||||||
<setting id="ssa_settings" type="action" label="$ADDON[script.module.ttml2ssa 32201]" option="close" action="Addon.OpenSettings(script.module.ttml2ssa)" visible="eq(-1,true)" subsetting="true"/>
|
<setting id="ssa_settings" type="action" label="$ADDON[script.module.ttml2ssa 32201]" option="close" action="Addon.OpenSettings(script.module.ttml2ssa)" visible="eq(-1,true)" subsetting="true"/>
|
||||||
<setting label="30019" type="action" id="is_settings" action="Addon.OpenSettings(inputstream.adaptive)" enable="System.HasAddon(inputstream.adaptive)" option="close"/>
|
<setting label="30019" type="action" id="is_settings" action="Addon.OpenSettings(inputstream.adaptive)" enable="System.HasAddon(inputstream.adaptive)" option="close"/>
|
||||||
<!--
|
<!--
|
||||||
<setting type="sep"/>
|
<setting type="sep"/>
|
||||||
<setting label="30450" type="action" id="donation" action="RunPlugin(plugin://plugin.video.movistarplus?action=show_donation_dialog)" option="close"/>
|
<setting label="30450" type="action" id="donation" action="RunPlugin(plugin://plugin.video.kodimovplus?action=show_donation_dialog)" option="close"/>
|
||||||
-->
|
-->
|
||||||
|
<setting id="proxy_address" type="text" label="" value="" default="" visible="false"/>
|
||||||
</category>
|
</category>
|
||||||
<category label="30002">
|
<category label="30002">
|
||||||
<setting label="30011" type="bool" id="manifest_modification" default="true"/>
|
<setting label="30011" type="bool" id="manifest_modification" default="true"/>
|
||||||
@@ -33,8 +40,8 @@
|
|||||||
<setting label="30301" type="action" action="InstallAddon(service.iptv.manager)" option="close" visible="!System.HasAddon(service.iptv.manager)"/>
|
<setting label="30301" type="action" action="InstallAddon(service.iptv.manager)" option="close" visible="!System.HasAddon(service.iptv.manager)"/>
|
||||||
<setting label="30302" type="bool" id="iptv.enabled" default="true" visible="System.HasAddon(service.iptv.manager)" />
|
<setting label="30302" type="bool" id="iptv.enabled" default="true" visible="System.HasAddon(service.iptv.manager)" />
|
||||||
<setting label="30303" type="action" action="Addon.OpenSettings(service.iptv.manager)" enable="eq(-1,true)" option="close" visible="System.HasAddon(service.iptv.manager)" subsetting="true"/>
|
<setting label="30303" type="action" action="Addon.OpenSettings(service.iptv.manager)" enable="eq(-1,true)" option="close" visible="System.HasAddon(service.iptv.manager)" subsetting="true"/>
|
||||||
<setting id="iptv.channels_uri" default="plugin://plugin.video.movistarplus/iptv/channels" visible="false"/>
|
<setting id="iptv.channels_uri" default="plugin://plugin.video.kodimovplus/iptv/channels" visible="false"/>
|
||||||
<setting id="iptv.epg_uri" default="plugin://plugin.video.movistarplus/iptv/epg" visible="false"/>
|
<setting id="iptv.epg_uri" default="plugin://plugin.video.kodimovplus/iptv/epg" visible="false"/>
|
||||||
</category>
|
</category>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@@ -46,17 +53,21 @@
|
|||||||
</category>
|
</category>
|
||||||
|
|
||||||
<category label="30304">
|
<category label="30304">
|
||||||
<setting label="30316" type="action" id="create_iptv_settings" action="RunPlugin(plugin://plugin.video.movistarplus/?action=create_iptv_settings)" option="close"/>
|
<setting label="30316" type="action" id="create_iptv_settings" action="RunPlugin(plugin://plugin.video.kodimovplus/?action=create_iptv_settings)" option="close"/>
|
||||||
|
|
||||||
<setting type="sep"/>
|
<setting type="sep"/>
|
||||||
|
<!--
|
||||||
<setting label="30317" type="bool" id="use_external_epg" default="true"/>
|
<setting label="30317" type="bool" id="use_external_epg" default="true"/>
|
||||||
<setting label="30318" id="epg_url" type="text" default="https://raw.githubusercontent.com/davidmuma/EPG_dobleM/master/guiafanart_color.xml.gz" enable="eq(-1,true)"/>
|
<setting label="30318" id="epg_url" type="text" default="https://raw.githubusercontent.com/davidmuma/EPG_dobleM/master/guiafanart_color.xml.gz" enable="eq(-1,true)"/>
|
||||||
<setting type="sep"/>
|
<setting type="sep"/>
|
||||||
|
-->
|
||||||
|
|
||||||
<setting label="30305" type="bool" id="export_epg" default="false"/>
|
<setting label="30508" type="bool" id="export_channels" 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 label="30315" type="number" id="export_days" default="2" enable="eq(-1,true)"/>
|
||||||
<setting type="sep"/>
|
<setting type="sep"/>
|
||||||
<setting label="30312" type="action" id="export_epg_now" action="RunPlugin(plugin://plugin.video.movistarplus/?action=export_epg_now)" option="close"/>
|
<setting label="30312" type="action" id="export_epg_now" action="RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now)" option="close"/>
|
||||||
|
|
||||||
<setting type="sep"/>
|
<setting type="sep"/>
|
||||||
<setting label="30308" type="action" action="Addon.OpenSettings(pvr.iptvsimple)" option="close" visible="System.HasAddon(pvr.iptvsimple)"/>
|
<setting label="30308" type="action" action="Addon.OpenSettings(pvr.iptvsimple)" option="close" visible="System.HasAddon(pvr.iptvsimple)"/>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<width>1030</width>
|
<width>1030</width>
|
||||||
<height>70</height>
|
<height>70</height>
|
||||||
<font>font20_title</font>
|
<font>font20_title</font>
|
||||||
<label>[B]$INFO[System.AddonTitle(plugin.video.movistarplus)][/B]</label>
|
<label>[B]$INFO[System.AddonTitle(plugin.video.kodimovplus)][/B]</label>
|
||||||
<align>center</align>
|
<align>center</align>
|
||||||
<aligny>center</aligny>
|
<aligny>center</aligny>
|
||||||
<shadowcolor>FF000000</shadowcolor>
|
<shadowcolor>FF000000</shadowcolor>
|
||||||
|
|||||||
+61
-29
@@ -4,51 +4,83 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcgui
|
import xbmcgui
|
||||||
from resources.lib.proxy import Proxy
|
from resources.lib.proxy import Proxy
|
||||||
from resources.lib.addon import *
|
from resources.lib.addon import *
|
||||||
from resources.lib.log import LOG
|
from resources.lib.log import LOG
|
||||||
from resources.lib.cache import Cache
|
from resources.lib import kodimov
|
||||||
|
|
||||||
def export_epg():
|
def file_age(filename):
|
||||||
from datetime import datetime, timedelta
|
"""Seconds since the file was last written (or found unchanged)."""
|
||||||
|
try:
|
||||||
def needs_update(filename):
|
return time.time() - os.path.getmtime(filename)
|
||||||
if os.path.exists(filename):
|
except OSError:
|
||||||
file_modification_time = datetime.fromtimestamp(os.path.getmtime(filename))
|
return float('inf')
|
||||||
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.movistarplus/?action=export_epg_now)')
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
LOG('Service started')
|
LOG('Service started')
|
||||||
proxy = Proxy()
|
proxy = Proxy()
|
||||||
proxy.start()
|
proxy.start()
|
||||||
LOG('proxy_address: {}'.format(proxy.proxy_address))
|
LOG('proxy_address: {}'.format(proxy.proxy_address))
|
||||||
cache = Cache(profile_dir)
|
|
||||||
cache.save_file('proxy.txt', proxy.proxy_address)
|
|
||||||
|
|
||||||
if addon.getSettingBool('export_epg'):
|
|
||||||
try:
|
|
||||||
export_epg()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
# Refreshes the TV guide channel list (new channels, new numbers) and the
|
||||||
|
# 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()
|
monitor = xbmc.Monitor()
|
||||||
while not monitor.abortRequested():
|
while not monitor.abortRequested():
|
||||||
if monitor.waitForAbort(1):
|
if monitor.waitForAbort(1):
|
||||||
break
|
break
|
||||||
|
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()
|
proxy.stop()
|
||||||
Reference in new issue
Block a user