Compare commits

...
13 Commits
Author SHA1 Message Date
XoséandClaude Opus 5.5 b62bf07a2b 1.0.0+kodimov.14: guide reload no longer skipped while the remote is in use; remove empty instances
- .190 on 2026-11-13: the box came on with an old epg.xml, Kodi stored it
  (no current programmes for 124 channels) and doesn't ask again for up to
  an hour. KodiMov+ wrote a new guide two minutes later, but the reload
  waited for 30 s without a key press and gave up after 10 minutes, while
  the guide was being browsed. It now waits for a 10 s pause, for as long
  as Kodi runs.
- Instances with no channel list (the "Migrated Add-on Config" Kodi makes
  when IPTV Simple is installed again) are removed once KodiMov+'s own
  instance exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:35:56 +01:00
XoséandClaude Opus 5.5 7873b50203 1.0.0+kodimov.13: updates don't stop live TV; IPTV Simple restarts only when idle
- After an add-on update the new service found port 57031 still held by the
  old proxy for a moment and moved to a random port; the stream playing kept
  asking 57031 for its manifest and died a minute later (.49, 2026-11-13).
  The proxy now waits up to 10 s for the port, and SO_REUSEADDR /
  allow_reuse_address are set before binding (upstream set them after, so
  they did nothing).
- IPTV Simple is restarted only when nothing plays and no key was pressed
  for 30 s: on .49 a channel opened during a restart failed with an error.
- After start or update, changed instance files (duplicate removed, refresh
  timer) are picked up by the guide reload instead of a restart of their
  own; the service restarts IPTV Simple itself only if that reload didn't
  happen within 10 minutes. 3 restarts after kodimov.12 -> 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:16:30 +01:00
XoséandClaude Opus 5.5 69a37ff064 1.0.0+kodimov.12: no duplicate TV guide instance; retry the web player registration
- The TV guide setup always wrote instance-settings-91.xml, so on a box
  already set up with another instance (from the original add-on) both read
  channels.m3u8 and every channel showed twice. The setup now rewrites the
  instance that already reads the list, and the service deletes extra ones
  at start (keeps the one that isn't 91, which holds what was watched) and
  restarts IPTV Simple when nothing plays.
- The web player registration for the new servers got one 401 error page
  and gave up, and the TV guide list then lacked LALIGA, DAZN LALIGA and
  Liga de Campeones until the next export 6 hours later. The registration
  is tried 3 times, and after a failure the service exports the list again
  every half hour, up to 6 times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:38:12 +01:00
XoséandClaude Fable 5.2 b3bc561c35 1.0.0+kodimov.11: Movistar's channel numbers with more than one IPTV Simple instance
- Kodi only lets "use channel numbers from backend" be read or changed while
  it applies: one PVR instance loaded, or several and "always" on. With a
  second instance (Radios de Vigo, a leftover empty one) it refused the
  setting the TV guide setup made, and numbered the channels 1, 2, 3...
- The service now checks it every 30 minutes and after the setup: if the
  setting is hidden it turns "always" on, and it turns the setting on once
  (marker file numbers-set). It never turns anything off, and turning it off
  by hand afterwards is respected. Existing setups are put right this way.
- KodiMov+ and Radios de Vigo take turns to restart IPTV Simple, through the
  pvr.iptvsimple.restarting property of Kodi's home window.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 13:41:35 +01:00
XoséandClaude Fable 5.2 e72e3d4902 1.0.0+kodimov.10: LALIGA, DAZN LALIGA and Liga de Campeones play again
- These 25 channels are missing from the amazon.tv channel list and their
  streams answer 403 to the CDN token of the classic servers. They are now
  taken from the web player's channel list and guide and opened with a CDN
  token from Movistar's new servers (live and catch-up). Stream session and
  licence stay on the classic servers. The other channels are untouched.
- The new servers need a device registered as a web player and the Movistar
  home network: a box registered otherwise gets one extra registration;
  away from the home network the channels are not listed.
- The CDN token is used for its 12 hours and renewed in the background: the
  server that gives it out at times refuses everything for minutes.
- New setting, off by default: always play at the best quality, up to 1080p
  (InputStream Adaptive stays at 576p on these streams when left on automatic).
- Error 4027 is shown only when the retry after registering the device again
  fails too; the first licence request for these channels always gets it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 13:17:20 +01:00
XoséandClaude Opus 5.5 e16dd66f63 1.0.0+kodimov.9: get a new guide into Kodi right after start or update
Kodi keeps its own copy of the programme guide and fetches it again only
when IPTV Simple reloads its files, on its refresh timer; restarting IPTV
Simple is not enough. After the first refresh following a Kodi start or
add-on update, if the guide changed and nothing is playing, restart IPTV
Simple with a 1-minute timer, wait for the reload, then restart it with
the hourly timer again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:51:51 +01:00
XoséandClaude Opus 5.5 5df60a1fb8 1.0.0+kodimov.8: guide refresh doesn't fail at random; retry after 30 min
datetime.strptime sometimes fails in Kodi's embedded Python ('NoneType'
object is not callable), which aborted the whole guide export. Build the
XMLTV times without it.

The service now checks every 30 minutes and refreshes when the files are
over 6 hours old, so a failed refresh is retried soon. write_if_changed
touches an unchanged file so its time means "last refreshed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:36:21 +01:00
XoséandClaude Opus 5.5 9faaa482fc 1.0.0+kodimov.7: synopsis in the programme guide; hourly IPTV Simple reload
Movistar's guide already carries the synopsis (Resena), genre, year,
age rating and pictures; write them to epg.xml.

IPTV Simple was set to reload the channel list and guide once a day at
4:00, so a new epg.xml didn't reach Kodi until then. Use its hourly
reload, which also makes Kodi fetch the guide again. The service switches
existing TV guide setups over once and restarts IPTV Simple when nothing
is playing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:31:24 +01:00
XoséandClaude Opus 5.5 09d6a68f74 1.0.0+kodimov.6: keep refreshing the programme guide on boxes set up with kodimov.1
kodimov.1 shipped the automatic guide export off. Kodi stored that value,
so the later default of on never applied and only the channel list was
refreshed; the 2-day guide ran out. Rename the setting so the new default
applies, and turn it on from the one-tap TV guide setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:08:57 +01:00
xockoandClaude Opus 5.5 d74c33011b Clear message when the TV guide setup runs before logging in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:31:54 +01:00
xockoandClaude Opus 5.5 3940d1b3e6 1.0.0+kodimov.5: write the programme guide before creating the TV guide
"Create settings for IPTV Simple" created the IPTV Simple instance and
then exported channels and EPG. IPTV Simple could load before epg.xml
existed, Kodi then stored an empty guide for every channel and didn't
ask again for hours (seen on a fresh box). Export first, and stop with
an error if the guide file wasn't written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:31:41 +01:00
xockoandClaude Opus 5.5 c9f062206c 1.0.0+kodimov.4: password-less first login from the Movistar network
On a new box the home-network login now also finds the account number
(accountInfo) and registers the box as a device, so no email, password
or account number is needed. The new device id is saved straight away,
so a retry reuses it instead of adding another one. Error pages from the
new servers (including HTML 401s) are retried.

Tested live on 2026-11-07 with a fresh profile; the test devices were
deleted afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:13:31 +01:00
xockoandClaude Opus 5.5 c4a0a7e97d 1.0.0+kodimov.3: one-tap TV guide setup
"Create settings for IPTV Simple" now also:
- turns on Kodi's "use backend channel numbers", so the guide shows
  Movistar's numbers
- reloads the channel list daily at 04:00
- restarts IPTV Simple so the new setup loads without restarting Kodi
and writes valid XML (the catch-up format had a bare &).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:59:01 +01:00
14 changed files with 777 additions and 97 deletions

No files matched your search

+13 -3
View File
@@ -4,13 +4,23 @@ Fork of [Paco8's Movistar+ add-on](https://github.com/Paco8/plugin.video.movista
`https://git.anosanube.ovh/xocko/kodi-repo/raw/branch/main/repository.xocko.zip`
What it adds:
- **Login from the Movistar home network**, no password. The home-network login gives a token for Movistar's new servers, which is traded once for a classic token; the classic servers renew it every 10 days. It needs the account number (taken from an existing login, or set in the settings) and an already registered device.
- **Login from the Movistar home network**, no email, password or account number. On first open, on a box connected to the Movistar home network, KodiMov+ gets a home-network token, finds the account, registers the box as one device (like a password login does) and trades the token for a classic login, which the classic servers renew every 10 days. Outside the Movistar network, log in once with the email and password (Accounts > Cuenta 1 > Login with username).
- **Takes over the login of plugin.video.movistarplus** on first run (same registered device, so no new device is added to the account).
- **Hides channels it can't play**: Movistar marks football and premium sports with `QMWatermark`; LALIGA and Liga de Campeones refuse their manifest, while M+ Deportes, DAZN and DAZN Baloncesto play and are kept (`resources/lib/kodimov.py`).
- **Plays LALIGA, DAZN LALIGA and Liga de Campeones** (25 channels, live and catch-up). Upstream 1.0.x doesn't list them (they are missing from the `amazon.tv` channel list it reads) and their streams answer 403 to the CDN token of the classic servers. KodiMov+ takes them from the web player's channel list and guide and opens them with a CDN token from Movistar's new servers; stream session and licence stay on the classic servers, like any other channel. The new servers only start a session for a device registered as a web player and, without a password, only from the Movistar home network, so:
- away from the Movistar home network these channels are not listed;
- a box that logged in with a password (or took over the login of the original add-on) is registered once more, as a web player: one extra device on the Movistar account;
- the CDN token lasts 12 hours and is renewed in the background, because the server that gives it out at times refuses everything for a few minutes.
- the licence server refuses the first request for one of these channels with error 4027 every time; upstream's "Try to fix error 4027" (register the device again and retry) gets the licence, and KodiMov+ shows the error only if that retry fails too.
- **Optional full HD**: left to itself, InputStream Adaptive starts every Movistar stream at 576p and never gets to the 720p/1080p versions (they use a different key). The setting "Always play at the best quality, up to 1080p" makes it start on the best one. Off by default.
- **Hides channels it can't play**: Movistar marks football and premium sports with `QMWatermark`; M+ Deportes, DAZN and DAZN Baloncesto play and are kept, the rest of the marked ones are hidden unless they come through the new servers (`resources/lib/kodimov.py`).
- **Extra free channels** in the TV guide list: TVG2 (10) and TeleVigo (15).
- **The TV guide stays current**: channel list and programme guide re-exported every 6 hours, numbered with Movistar's own dial numbers. Listings come from Movistar (with catch-up links) for its channels and from the public EPG_dobleM guide for the extras.
- **Gets along with Radios de Vigo** (`service.radios.vigo`), which keeps its own IPTV Simple instance for the Radio section. Kodi only lets *Use channel numbers from backend* be changed while it applies (one PVR instance loaded, or several and *always* on), so with a second instance it had refused the setting. Both add-ons now check it every so often: if it is hidden they turn *always* on, and they turn the setting itself on once; neither ever turns anything off, and turning it off by hand afterwards is respected. They also take turns to restart IPTV Simple, through the `pvr.iptvsimple.restarting` property of Kodi's home window.
- **One IPTV Simple instance per channel list**: the TV guide setup rewrites the instance that already reads KodiMov+'s list (one set up with the original add-on, or by hand) instead of adding `instance-settings-91.xml` next to it, and the service deletes any extra one at Kodi start (keeping the one that isn't 91, which holds what was watched): two of them showed every channel twice.
- Fixes the programme guide export of upstream 1.0.0 (the amazon.tv guide is a flat list).
- Its own proxy port (57031), so it can run next to the original.
- Its own proxy port (57031), so it can run next to the original. After an update the new proxy waits for the old one to free the port instead of moving to a random one, so the channel being watched keeps playing (upstream sets `allow_reuse_address` and `SO_REUSEADDR` too late to take effect).
- **Restarts IPTV Simple only while idle**: nothing playing and no key pressed for 10 seconds (a channel opened during a restart fails). It waits for that as long as Kodi runs: after the box was off, Kodi first loads the old guide file and keeps it (no current programmes) until the reload. After start or update a single guide reload also picks up changed instance files.
- **Removes empty IPTV Simple instances** (no channel list, like the "Migrated Add-on Config" Kodi creates when IPTV Simple is installed again) once KodiMov+'s own instance is there.
---
+16 -4
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.kodimovplus"
name="KodiMov+"
version="1.0.0+kodimov.2"
version="1.0.0+kodimov.14"
provider-name="Paco8, xocko">
<requires>
<!--- <import addon="xbmc.python" version="2.25.0"/> -->
@@ -19,9 +19,21 @@
<extension point="xbmc.addon.metadata">
<summary lang="en_GB">Movistar+ for Kodi, with automatic login from the Movistar home network</summary>
<summary lang="es_ES">Movistar+ para Kodi, con inicio de sesión automático desde la red de Movistar</summary>
<description lang="en_GB">Fork of Paco8's Movistar+ add-on (plugin.video.movistarplus). Logs in by itself from the Movistar home network, keeps the channel list and numbers up to date, hides the channels that need stronger DRM than Kodi has, adds free Galician channels (TVG2, TeleVigo) and writes a programme guide for every channel. Requires a Movistar+ subscription.</description>
<description lang="es_ES">Fork del addon Movistar+ de Paco8 (plugin.video.movistarplus). Inicia sesión solo desde la red de Movistar, mantiene la lista y los números de los canales al día, oculta los canales que necesitan una protección que Kodi no tiene añade canales gallegos gratuitos (TVG2, TeleVigo) y genera la guía de programación de todos los canales. Se necesita estar abonado a Movistar+.</description>
<news>1.0.0+kodimov.2: programme guide for every channel (Movistar + TVG2/TeleVigo), TeleVigo on 15, silent background refresh.
<description lang="en_GB">Fork of Paco8's Movistar+ add-on (plugin.video.movistarplus). Logs in by itself from the Movistar home network, keeps the channel list and numbers up to date, plays the LALIGA, DAZN LALIGA and Liga de Campeones channels through Movistar's new servers (from the Movistar home network), hides the channels it can't play, adds free Galician channels (TVG2, TeleVigo) and writes a programme guide for every channel. Requires a Movistar+ subscription.</description>
<description lang="es_ES">Fork del addon Movistar+ de Paco8 (plugin.video.movistarplus). Inicia sesión solo desde la red de Movistar, mantiene la lista y los números de los canales al día, reproduce los canales de LALIGA, DAZN LALIGA y Liga de Campeones a través de los servidores nuevos de Movistar (desde la red de Movistar de casa), oculta los canales que no puede reproducir, añade canales gallegos gratuitos (TVG2, TeleVigo) y genera la guía de programación de todos los canales. Se necesita estar abonado a Movistar+.</description>
<news>1.0.0+kodimov.14: the programme guide is no longer left empty for most channels after the box was off for a while and the remote is in use: the guide reload now waits for a 10-second pause instead of 30 seconds and doesn't give up after 10 minutes. Empty IPTV Simple instances ("Migrated Add-on Config", left when IPTV Simple is installed again) are removed.
1.0.0+kodimov.13: updating the add-on no longer stops the channel being watched (the proxy got a new port and the stream lost it). IPTV Simple is restarted only while nothing plays and nobody has used the remote for 30 seconds, and fewer times after an update.
1.0.0+kodimov.12: no more channels twice in the TV guide on boxes where the TV guide setup was run over an existing setup (the extra IPTV Simple instance is removed, and the setup now rewrites the existing one). LALIGA, DAZN LALIGA and Liga de Campeones no longer go missing for 6 hours when Movistar's new servers refuse the box's registration as a web player once: it is tried again, and the TV guide list is rewritten half an hour later.
1.0.0+kodimov.11: Movistar's channel numbers also show on boxes with more than one IPTV Simple instance (Radios de Vigo, a leftover empty one), where Kodi had refused the setting and numbered the channels 1, 2, 3...; existing TV guide setups are put right by themselves. KodiMov+ and Radios de Vigo no longer restart IPTV Simple at the same time.
1.0.0+kodimov.10: the LALIGA, DAZN LALIGA and Liga de Campeones channels are back, live and catch-up, on boxes connected to the Movistar home network (their streams are now served only through Movistar's new servers). New setting to always play at the best quality, up to 1080p, instead of the 576p Kodi stays at on automatic (off by default). The "Error 4027" message no longer pops up when the add-on recovers from it by itself.
1.0.0+kodimov.9: after Kodi starts or the add-on updates, a new programme guide shows up in Kodi within a couple of minutes instead of at the next hourly reload (only while nothing is playing).
1.0.0+kodimov.8: the programme guide refresh no longer fails at random inside Kodi, and a failed refresh is tried again half an hour later instead of 6 hours later.
1.0.0+kodimov.7: the programme guide has the synopsis, picture, genre, year and age rating of each programme. IPTV Simple now reloads the guide every hour instead of once a day, so a new guide shows up in Kodi by itself (existing TV guide setups are switched over once).
1.0.0+kodimov.6: the programme guide refreshes on its own again on boxes first set up with kodimov.1, where the automatic guide export had stayed off (the guide ran out after 2 days).
1.0.0+kodimov.5: TV guide setup writes the programme guide before creating the IPTV Simple settings, so the guide isn't empty on a new box.
1.0.0+kodimov.4: first login on a new box needs no password from the Movistar home network: the account is found and the box registered automatically.
1.0.0+kodimov.3: one-tap TV setup: "Create settings for IPTV Simple" also turns on Movistar channel numbers, daily list reload, and restarts IPTV Simple; fixes invalid XML in that file.
1.0.0+kodimov.2: programme guide for every channel (Movistar + TVG2/TeleVigo), TeleVigo on 15, silent background refresh.
1.0.0+kodimov.1: first KodiMov+ release, based on Movistar+ 1.0.0.</news>
<disclaimer lang="en_GB">This addon is not officially commissioned/supported by Movistar+. All product names, logos, and trademarks mentioned in this project are property of their respective owners.</disclaimer>
<disclaimer lang="es_ES">Este addon no ha sido realizado ni apoyado por Movistar+. Todos los nombres de productos, logotipos y marcas registradas mencionados en este proyecto son propiedad de sus respectivos dueños.</disclaimer>
@@ -643,7 +643,7 @@ msgid "Log in automatically from the Movistar home network"
msgstr ""
msgctxt "#30506"
msgid "Hide channels this add-on can't play (LALIGA, Liga de Campeones)"
msgid "Hide channels this add-on can't play"
msgstr ""
msgctxt "#30507"
@@ -653,3 +653,15 @@ msgstr ""
msgctxt "#30508"
msgid "Keep the TV guide channel list up to date"
msgstr ""
msgctxt "#30509"
msgid "Log in first: there is no channel list yet"
msgstr ""
msgctxt "#30510"
msgid "Can't play this channel: {}"
msgstr ""
msgctxt "#30511"
msgid "Always play at the best quality, up to 1080p (on automatic, Kodi stays at 576p)"
msgstr ""
@@ -606,8 +606,8 @@ msgid "Log in automatically from the Movistar home network"
msgstr "Iniciar sesión automáticamente desde la red de Movistar"
msgctxt "#30506"
msgid "Hide channels this add-on can't play (LALIGA, Liga de Campeones)"
msgstr "Ocultar los canales que no se pueden reproducir (LALIGA, Liga de Campeones)"
msgid "Hide channels this add-on can't play"
msgstr "Ocultar los canales que no se pueden reproducir"
msgctxt "#30507"
msgid "Add free Galician channels (TVG2, TeleVigo) to the TV guide"
@@ -617,6 +617,18 @@ msgctxt "#30508"
msgid "Keep the TV guide channel list up to date"
msgstr "Mantener al día la lista de canales de la guía"
msgctxt "#30509"
msgid "Log in first: there is no channel list yet"
msgstr "Inicia sesión primero: todavía no hay lista de canales"
msgctxt "#30510"
msgid "Can't play this channel: {}"
msgstr "No se puede reproducir este canal: {}"
msgctxt "#30511"
msgid "Always play at the best quality, up to 1080p (on automatic, Kodi stays at 576p)"
msgstr "Reproducir siempre con la mejor calidad, hasta 1080p (en automático, Kodi se queda en 576p)"
#~ msgctxt "#30040"
#~ msgid "Use only for these languages (e.g.: es en pt):"
#~ msgstr "Usar solo para estos idiomas (ej: es en pt):"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -15,8 +15,8 @@ def save_iptv_settings(filename, name, addon_id, epg_url=None):
<setting id="m3uCache" default="true">true</setting>
<setting id="startNum" default="true">1</setting>
<setting id="numberByOrder" default="true">false</setting>
<setting id="m3uRefreshMode" default="true">0</setting>
<setting id="m3uRefreshIntervalMins" default="true">60</setting>
<setting id="m3uRefreshMode">1</setting>
<setting id="m3uRefreshIntervalMins">60</setting>
<setting id="m3uRefreshHour" default="true">4</setting>
<setting id="connectioncheckinterval" default="true">10</setting>
<setting id="connectionchecktimeout" default="true">20</setting>
@@ -82,7 +82,7 @@ def save_iptv_settings(filename, name, addon_id, epg_url=None):
content += '''
<setting id="catchupEnabled">true</setting>
<setting id="catchupQueryFormat">&start_time={utc}&end_time={utcend}</setting>
<setting id="catchupQueryFormat">&amp;start_time={utc}&amp;end_time={utcend}</setting>
<setting id="allChannelsCatchupMode">2</setting>'''
content += '''
+536 -37
View File
@@ -6,6 +6,8 @@
# - takes over the login of plugin.video.movistarplus on first run
# - logs in from the Movistar home network without a password
# - hides channels this add-on can't play
# - plays the channels that only Movistar's new servers serve (LALIGA,
# DAZN LALIGA, Liga de Campeones)
# - extra free channels for the TV guide
from __future__ import unicode_literals, absolute_import, division
@@ -22,8 +24,9 @@ ORIGINAL_ADDON = 'plugin.video.movistarplus'
CLASSIC_ISSUER = 'http://www.movistarplus.es'
# Movistar marks the football and premium sports channels with QMWatermark.
# The LALIGA and Liga de Campeones ones refuse their manifest to this add-on;
# these play fine (tested 2026-11-07 on a Raspberry Pi 5, LibreELEC 12.2).
# These play fine (tested 2026-11-07 on a Raspberry Pi 5, LibreELEC 12.2).
# LALIGA, DAZN LALIGA and Liga de Campeones are a separate case: see
# "Channels that only the new servers serve" below.
WATERMARKED_BUT_PLAYABLE = {
'CPDEP', 'ARTHUR', 'USOP2', 'USOP3', 'USOP11', 'MULTI8', # M+ Deportes 1-6
'M1SD', 'M2SD', 'DAZN3', 'DAZN4', # DAZN 1-4
@@ -46,12 +49,38 @@ EXTRA_EPG_URL = 'https://raw.githubusercontent.com/davidmuma/EPG_dobleM/master/g
AUTOLOGIN_URL = 'https://soter-pf.svp.video.telefonicaservices.com/service/login/autologin?deviceId={device}&deviceType=webplayer&_={ts}'
SOTER_INITDATA_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/login/initData?legacy=true'
SOTER_ACCOUNTINFO_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/login/webplayer/accountInfo?legacy=true'
SOTER_DEVICES_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/login/devices?deviceType=webplayer&accountId={account}&legacy=true'
SOTER_CDNTOKEN_URL = 'https://soter-pf.sve.video.telefonicaservices.com/service/tcdnidserver/{account}/devices/webplayer/cdn/token/refresh'
def is_playable(channel):
return not channel.get('watermark') or channel['id'] in WATERMARKED_BUT_PLAYABLE
def dial_number(channel):
try:
return int(channel.get('dial') or 0)
except (TypeError, ValueError):
return 0
def playable_channels(m, channels):
"""The channels this box can play. The ones served by the new servers
('new_api') play only where a session on those servers can be had."""
ready = None
res = []
for c in channels:
if c.get('new_api'):
if c.get('subscribed') and ready is None:
ready = new_api_ready(m)
if ready or not c.get('subscribed'): # not subscribed: greyed out like any other
res.append(c)
elif is_playable(c):
res.append(c)
return res
def migrate_from_original(config_directory):
"""Copies the login of plugin.video.movistarplus (tokens, registered device,
credentials) the first time KodiMov+ runs, so no new device is added to the
@@ -93,64 +122,321 @@ def find_account_number(m, setting=''):
return (setting or '').strip()
def network_login(m, account_number):
"""Password-less login from the Movistar home network.
1. autologin (only reachable inside Movistar's network) gives a 5-minute
token for the new servers;
2. their initData trades it for a legacyAccessToken, which the classic
servers this add-on uses accept (and renew for 10 days at a time).
Returns (success, reason)."""
def _autologin(m, device_id):
"""5-minute token for the new servers, bound to device_id. Only works from
the Movistar home network. Returns the token or raises."""
try:
from urllib.parse import urlsplit
except ImportError:
from urlparse import urlsplit
from .signature import get_auth
device_id = (m.cache.load_file('device_id.conf') or '').strip().strip('"')
if not account_number: return False, 'no account number'
if not device_id: return False, 'no registered device'
headers = m.net.headers.copy()
headers['Content-Type'] = 'application/json'
url = AUTOLOGIN_URL.format(device=device_id, ts=int(time.time() * 1000))
try:
r = m.net.session.get(url, headers=headers, allow_redirects=False, timeout=(4, 10))
location = r.headers.get('Location', '')
if r.status_code in (301, 302, 303, 307, 308) and location:
if not urlsplit(location).netloc.endswith('.telefonicaservices.com'):
return False, 'unexpected redirect'
raise ValueError('unexpected redirect')
h2 = headers.copy()
h2['Origin'] = 'null'
r = m.net.session.get(location, headers=h2, allow_redirects=False, timeout=(4, 10))
token = r.json().get('access_token')
if not token: raise ValueError('autologin refused')
return token
def _soter(m, method, url, token, body=None, attempts=6):
"""Signed call to the new servers. They often answer with an HTML error
page (sometimes a 401) for no reason, so retry those."""
from .signature import get_auth
r = None
for attempt in range(attempts):
h = m.net.headers.copy()
h['Content-Type'] = 'application/json'
h['Accept'] = 'application/json, text/javascript, */*; q=0.01'
h['Authorization'] = get_auth(method, url, token)
try:
r = m.net.session.request(method.upper(), url, headers=h, timeout=(4, 15),
data=json.dumps(body) if body is not None else None)
text = r.content.decode('utf-8', errors='replace')
if not text.lstrip().startswith('<'):
return r.status_code, text
except Exception as e:
LOG('soter {} failed: {}'.format(url.split('?')[0], e))
LOG('soter {}: attempt {} got an error page ({})'.format(url.split('?')[0], attempt + 1, getattr(r, 'status_code', '?')))
if attempt + 1 < attempts: time.sleep(5)
return getattr(r, 'status_code', 0), ''
def network_login(m, account_number=''):
"""Password-less login from the Movistar home network.
1. autologin gives a 5-minute token for the new servers;
2. on a fresh box: accountInfo gives the account number and the device is
registered (one new device on the account, like a password login);
3. initData trades a device-bound token for a legacyAccessToken, which the
classic servers this add-on uses accept and renew for 10 days at a time.
Returns (success, reason)."""
import uuid
device_id = (m.cache.load_file('device_id.conf') or '').strip().strip('"')
try:
if not account_number or not device_id:
token = _autologin(m, device_id or uuid.uuid4().hex)
if not account_number:
status, text = _soter(m, 'get', SOTER_ACCOUNTINFO_URL, token)
try:
account_number = json.loads(text)['ofertas'][0]['accountNumber']
except (ValueError, KeyError, IndexError, TypeError):
return False, 'account not found ({})'.format(status)
if not device_id:
status, text = _soter(m, 'post', SOTER_DEVICES_URL.format(account=account_number), token, attempts=1)
new_id = text.strip().strip('"')
if status not in (200, 201) or len(new_id) != 32:
return False, 'device registration refused ({})'.format(status)
device_id = new_id
# Saved straight away, so a retry reuses it instead of adding another.
m.cache.save_file('device_id.conf', device_id)
LOG('network_login: registered device {}...'.format(device_id[:5]))
token = _autologin(m, device_id)
except Exception as e:
LOG('network_login: autologin failed: {}'.format(e))
return False, 'not on the Movistar network'
if not token: return False, 'autologin refused'
status, data = _init_data(m, token, account_number, device_id)
legacy = data.get('legacyAccessToken')
props = m.get_token_properties(legacy) if legacy else None
if not props or props.get('iss') != CLASSIC_ISSUER or props.get('exp', 0) < time.time():
return False, 'initData refused ({})'.format(status)
m.save_key_file({'access_token': legacy, 'token_type': 'bearer'})
m.cache.save_file('access_token.conf', legacy)
# The same answer carries the session for the channels of the new servers.
m.cache.save_file('web_device_id.conf', device_id)
_save_new_api_session(m, data, account_number)
LOG('network_login: OK')
return True, ''
def _init_data(m, token, account_number, device_id):
"""Starts a session on the new servers. Returns (status, answer); the
answer is {} when refused."""
body = {'accountNumber': account_number, 'sessionUserProfile': 0, 'isKidProfile': False,
'deviceId': device_id, 'streamMiscellanea': 'HTTPS', 'deviceManufacturerProduct': 'Firefox',
'streamDRM': 'Widevine', 'streamFormat': 'DASH'}
data = None
for attempt in range(4):
h = headers.copy()
h['Authorization'] = get_auth('post', SOTER_INITDATA_URL, token)
status, text = _soter(m, 'post', SOTER_INITDATA_URL, token, body)
try:
r = m.net.session.post(SOTER_INITDATA_URL, data=json.dumps(body), headers=h, timeout=(4, 15))
data = r.json()
data = json.loads(text)
except ValueError:
data = None
return status, data if isinstance(data, dict) else {}
# --- Channels that only the new servers serve ------------------------------
#
# The streams of LALIGA, DAZN LALIGA and Liga de Campeones answer 403 to the
# CDN token of the classic servers, and the amazon.tv channel list this add-on
# reads doesn't even list them. They open with a CDN token from the new
# servers (tested 2026-11-13). Those servers start a session only for a device
# registered as a web player, and, without a password, only from the Movistar
# home network. Everything else (stream session, licence) stays on the classic
# servers, as for any other channel.
def web_only_channels(m, channels, profile, demarcation):
"""Channels of the web player's list that are missing from `channels` (the
amazon.tv list), marked with 'new_api'."""
cache_filename = 'channels_web_{}.json'.format(m.quality)
data = None
content = m.cache.load(cache_filename)
if content:
try:
data = json.loads(content)
except ValueError:
data = None
if not isinstance(data, list):
url = m.endpoints['canales'].format(deviceType='webplayer', profile=profile, mdrm='true', demarcation=demarcation)
if m.quality == 'UHD': url += '&filterQuality=UHD'
data = m.net.load_data(url)
if isinstance(data, list):
m.cache.save_file(cache_filename, json.dumps(data, ensure_ascii=False))
else: # download failed: an old list is better than losing the channels
data = _read_json(m.cache, cache_filename)
if not isinstance(data, list): return []
known = set(c.get('CodCadenaTv') for c in channels)
res = []
for c in data:
if c.get('CodCadenaTv') and c['CodCadenaTv'] not in known:
c = dict(c)
c['new_api'] = True
res.append(c)
return res
def web_only_epg(m, date=None, duration=2):
"""Programme guide of the web-only channels, grouped by channel like the
web player's guide: the amazon.tv guide doesn't have them. Without a date:
from today, kept for 6 hours like the main guide."""
from datetime import datetime
channel_ids = [c['id'] for c in m.get_channels() if c.get('new_api')]
if not channel_ids: return []
cache_filename = None
if not date:
cache_filename = 'epg_web_{}_{}.json'.format(m.quality, duration)
try:
data = json.loads(m.cache.load(cache_filename, 6*60) or 'null')
except ValueError:
data = None
if isinstance(data, list): return data
date = datetime.today().strftime('%Y-%m-%dT00:00:00')
url = m.endpoints['rejilla'].format(deviceType='webplayer', profile=m.account['platform'], UTCDATETIME=date,
DURATION=duration, CHANNELS=','.join(channel_ids).replace('+', '%2B'),
NETWORK='movistarplus', mdrm='true', demarcation=m.account['demarcation'])
if m.quality == 'UHD': url += '&filterQuality=UHD'
data = m.net.load_data(url)
if not isinstance(data, list): return []
groups = {}
for item in data:
for p in (item if isinstance(item, list) else [item]):
try:
groups.setdefault(p['Canal']['CodCadenaTv'], []).append(p)
except (KeyError, TypeError):
pass
res = [groups[id] for id in channel_ids if id in groups]
if cache_filename and res:
m.cache.save_file(cache_filename, json.dumps(res, ensure_ascii=False))
return res
def _web_device_id(m, account_number):
"""Id of this box as a web player. A box that logged in from the home
network already is one; a box that logged in with a password is registered
as another kind of device, so it gets a second, web player, registration
(one more device on the Movistar account, once)."""
import uuid
device_id = (m.cache.load_file('web_device_id.conf') or '').strip()
if device_id: return device_id
own = (m.account.get('device_id') or '').strip('"')
devices = m.get_devices()
if not devices: raise ValueError('device list unavailable')
for d in devices:
if d['id'] == own and (d.get('type_code') or '').startswith('WP'):
m.cache.save_file('web_device_id.conf', own)
return own
token = _autologin(m, uuid.uuid4().hex)
# Like the other calls, it at times gets a 401 error page for no reason
# (seen 2026-11-13: refused once, registered on the next try)
status, text = _soter(m, 'post', SOTER_DEVICES_URL.format(account=account_number), token, attempts=3)
device_id = text.strip().strip('"')
if status not in (200, 201) or len(device_id) != 32:
raise ValueError('device registration refused ({})'.format(status))
m.cache.save_file('web_device_id.conf', device_id)
LOG('new_api: registered web player device {}...'.format(device_id[:5]))
return device_id
def _save_new_api_session(m, data, account_number):
token = data.get('accessToken')
props = m.get_token_properties(token) if token else None
if not props or props.get('exp', 0) < time.time(): return None
session = {'access_token': token, 'account': account_number, 'expires': props['exp']}
m.cache.save_file('new_api.json', json.dumps(session))
return session
def new_api_session(m, force=False):
"""Session on the new servers: {'access_token', 'account', 'expires'}. It
lasts a day. Raises ValueError with the reason when it can't be had."""
session = _read_json(m.cache, 'new_api.json')
if not force and isinstance(session, dict) and session.get('expires', 0) > time.time() + 600:
return session
account_number = m.account.get('id') or find_account_number(m)
if not account_number: raise ValueError('not logged in')
for attempt in (1, 2):
device_id = _web_device_id(m, account_number)
try:
token = _autologin(m, device_id)
except Exception as e:
LOG('new_api: autologin failed: {}'.format(e))
raise ValueError('not on the Movistar network')
status, data = _init_data(m, token, account_number, device_id)
session = _save_new_api_session(m, data, account_number)
if session:
LOG('new_api: session OK')
return session
# Refused for good when the device was removed from the account: start over.
devices = m.get_devices()
if attempt == 1 and devices and device_id not in [d['id'] for d in devices]:
LOG('new_api: web player device no longer on the account')
m.cache.remove_file('web_device_id.conf')
continue
break
except Exception:
# The new servers sometimes answer with an HTML error page; retry.
LOG('network_login: initData attempt {} failed ({})'.format(attempt + 1, getattr(r, 'status_code', '?')))
time.sleep(3)
legacy = data.get('legacyAccessToken') if isinstance(data, dict) else None
props = m.get_token_properties(legacy) if legacy else None
if not props or props.get('iss') != CLASSIC_ISSUER or props.get('exp', 0) < time.time():
return False, 'initData refused'
m.save_key_file({'access_token': legacy, 'token_type': 'bearer'})
m.cache.save_file('access_token.conf', legacy)
LOG('network_login: OK')
return True, ''
raise ValueError('initData refused ({})'.format(status))
def new_api_ready(m):
"""True on a box that can play the channels of the new servers. Once it
could, it stays true: a passing failure must not empty the TV guide. While
it can't, it tries again at most every 10 minutes."""
if m.cache.load_file('new_api.json'): return True
stamp = m.cache.config_directory + 'new_api.stamp'
if os.path.exists(stamp) and time.time() - os.path.getmtime(stamp) < 600:
return False
m.cache.save_file('new_api.stamp', str(int(time.time())))
try:
new_api_session(m)
return True
except Exception as e:
LOG('new_api: not available: {}'.format(e))
return False
def new_api_failed(profile_dir):
"""True if the last try to reach the new servers failed and none ever
worked: the TV guide list was written without LALIGA, DAZN LALIGA and
Liga de Campeones, so the service exports it again sooner."""
import glob
for stamp in glob.glob(os.path.join(profile_dir, 'account_*', 'new_api.stamp')):
if not os.path.exists(os.path.join(os.path.dirname(stamp), 'new_api.json')): return True
return False
def _token_life(m, token):
"""Seconds until the token expires (negative: expired or unreadable)."""
props = m.get_token_properties(token) if token else None
return (props or {}).get('exp', 0) - time.time()
def new_api_cdn_token(m, min_life=600, attempts=2):
"""CDN token from the new servers, for the manifest and the video of the
'new_api' channels. It lasts 12 hours and, like the web player does, it is
used until shortly before it expires: the server that gives it out sits
behind a filter that at times refuses everything for minutes. Raises
ValueError with the reason."""
cached = (m.cache.load_file('cdn_new.conf') or '').strip()
if _token_life(m, cached) > min_life: return cached
status, text = 0, ''
for force in (False, True):
session = new_api_session(m, force)
status, text = _soter(m, 'post', SOTER_CDNTOKEN_URL.format(account=session['account']),
session['access_token'], {}, attempts=attempts)
try:
token = json.loads(text).get('access_token')
except (ValueError, AttributeError):
token = None
if token:
m.cache.save_file('cdn_new.conf', token)
return token
if not text: break # error pages: a new session wouldn't get through either
if _token_life(m, cached) > 60: return cached # still good for now
raise ValueError('Movistar servers busy, try again in a minute' if not text
else 'CDN token refused ({})'.format(status))
def new_api_keep_warm(m):
"""Run now and then by the service: renews the CDN token hours before it
expires, so that opening a channel never has to wait for the new servers."""
if not m.cache.load_file('new_api.json'): return # this box doesn't use them
try:
new_api_cdn_token(m, min_life=6 * 3600, attempts=1)
except Exception as e:
LOG('new_api: keep warm failed: {}'.format(e))
def extra_channels_m3u8():
@@ -162,7 +448,9 @@ def extra_channels_m3u8():
def write_if_changed(filename, content):
try:
with io.open(filename, 'r', encoding='utf-8', newline='') as f:
if f.read() == content: return False
if f.read() == content:
os.utime(filename, None) # the service reads the time as "last refreshed"
return False
except (IOError, OSError):
pass
tmp = filename + '.tmp'
@@ -172,6 +460,217 @@ def write_if_changed(filename, content):
return True
def set_iptv_refresh(pvr_dir, addon_id, minutes=60):
"""Makes the IPTV Simple instances that read this add-on's files reload the
channel list and programme guide every few minutes. Each reload also makes
Kodi fetch the guide again. TV guide setups before kodimov.7 reloaded only
once a day, at 4:00. Returns True if a file changed (IPTV Simple must be
restarted to use it)."""
import glob
import re
changed = False
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
with io.open(filename, 'r', encoding='utf-8') as f:
content = f.read()
if 'plugin.video.{}/channels.m3u8'.format(addon_id) not in content: continue
new = content
for setting, value in (('m3uRefreshMode', '1'), ('m3uRefreshIntervalMins', str(minutes))):
new = re.sub(r'<setting id="{}"[^>]*?(/>|>[^<]*</setting>)'.format(setting),
'<setting id="{}">{}</setting>'.format(setting, value), new)
if new != content:
write_if_changed(filename, new)
LOG('set_iptv_refresh: {} minutes in {}'.format(minutes, filename))
changed = True
return changed
def reload_iptv_guide(pvr_dir, addon_id):
"""Gets a new programme guide into Kodi now instead of at the next hourly
reload. Restarting IPTV Simple isn't enough: Kodi keeps its own copy of the
guide and fetches it again only when IPTV Simple reloads, which happens on
its timer. So restart it with a 1-minute timer, wait for that reload, then
restart it with the hourly timer. Restarting stops live TV, so this does
nothing if something is playing."""
import xbmc
monitor = xbmc.Monitor()
if not wait_until_idle(): return
if not set_iptv_refresh(pvr_dir, addon_id, 1): return
try:
restart_iptv_simple()
monitor.waitForAbort(90)
finally:
set_iptv_refresh(pvr_dir, addon_id, 60)
if wait_until_idle(): # until then it keeps reloading every minute
restart_iptv_simple()
def is_idle(seconds=10):
"""Nothing playing and nobody pressed a key for a few seconds. Restarting
IPTV Simple stops live TV, and a channel opened while it restarts fails.
Short on purpose: the reload is most needed right when someone is
browsing a TV guide with missing programmes."""
import xbmc
return not xbmc.Player().isPlaying() and xbmc.getGlobalIdleTime() >= seconds
def wait_until_idle(timeout=None):
"""Waits until is_idle(), by default for as long as Kodi runs. False if
Kodi is closing or the timeout ran out."""
import xbmc
monitor = xbmc.Monitor()
end = time.time() + timeout if timeout else None
while not is_idle():
if monitor.waitForAbort(5) or (end and time.time() > end): return False
return not monitor.abortRequested()
# IPTV Simple is shared with other add-ons (Radios de Vigo keeps its own
# instance for the Radio section). Restarting it restarts every instance, so
# the add-ons take turns: whoever restarts it says so in this property of
# Kodi's home window (the time it started), and the others wait.
IPTV_RESTART_PROPERTY = 'pvr.iptvsimple.restarting'
# When this add-on last restarted it, so the service can skip a restart that
# one from the plugin (the guide reload) already did
LAST_RESTART_PROPERTY = 'kodimovplus.iptvsimple.restarted'
def last_iptv_restart():
import xbmcgui
try:
return float(xbmcgui.Window(10000).getProperty(LAST_RESTART_PROPERTY) or 0)
except ValueError:
return 0
def restart_iptv_simple():
"""Restarts IPTV Simple so it loads its instance settings again."""
import xbmc
import xbmcgui
window = xbmcgui.Window(10000)
for _ in range(40): # another add-on is restarting it: wait (a turn older than 20 s is stale)
try:
busy = 0 <= time.time() - float(window.getProperty(IPTV_RESTART_PROPERTY) or 0) < 20
except ValueError:
busy = False
if not busy: break
xbmc.sleep(1000)
window.setProperty(IPTV_RESTART_PROPERTY, str(time.time()))
window.setProperty(LAST_RESTART_PROPERTY, str(time.time()))
try:
for enabled in (False, True):
xbmc.executeJSONRPC(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Addons.SetAddonEnabled',
'params': {'addonid': 'pvr.iptvsimple', 'enabled': enabled}}))
xbmc.sleep(3000)
finally:
window.clearProperty(IPTV_RESTART_PROPERTY)
SETUP_INSTANCE_FILE = 'instance-settings-91.xml' # written by "Create IPTV settings"
def iptv_instance_files(pvr_dir, addon_id):
"""The IPTV Simple instance files that read this add-on's channel list,
the one to keep first: a box set up before (with the original add-on, or
by hand) has its instance besides 91, and that one holds what was watched;
91 is the one the TV guide setup writes."""
import glob
res = []
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
try:
with io.open(filename, 'r', encoding='utf-8') as f:
if 'plugin.video.{}/channels.m3u8'.format(addon_id) in f.read(): res.append(filename)
except (IOError, OSError):
pass
def number(filename):
try:
return int(os.path.basename(filename)[len('instance-settings-'):-len('.xml')])
except ValueError:
return 0
res.sort(key=lambda f: (os.path.basename(f) == SETUP_INSTANCE_FILE, number(f)))
return res
def has_iptv_instance(pvr_dir, addon_id):
"""True if an IPTV Simple instance reads this add-on's channel list."""
return bool(iptv_instance_files(pvr_dir, addon_id))
def _empty_instance_files(pvr_dir):
"""Instances with no channel list at all, like the "Migrated Add-on Config"
Kodi creates when IPTV Simple is installed again. They show nothing and
count as a second PVR instance (see ensure_channel_numbers)."""
import glob
import re
def value(content, setting):
match = re.search(r'<setting id="{}"[^>]*?(?:/>|>([^<]*)</setting>)'.format(setting), content)
return ((match and match.group(1)) or '').strip()
res = []
for filename in glob.glob(os.path.join(pvr_dir, 'instance-settings-*.xml')):
try:
with io.open(filename, 'r', encoding='utf-8') as f:
content = f.read()
except (IOError, OSError):
continue
if not value(content, 'm3uPath') and not value(content, 'm3uUrl'): res.append(filename)
return res
def remove_extra_instances(pvr_dir, addon_id):
"""Two instances reading the same channel list put every channel twice in
the TV guide. That happened when the TV guide setup (instance 91) was run
on a box already set up with another instance. Keeps one and deletes the
others, and deletes empty instances (no channel list) once this add-on's
is there. Returns True if a file was deleted (IPTV Simple must be
restarted to drop it)."""
ours = iptv_instance_files(pvr_dir, addon_id)
if not ours: return False
removed = False
for filename in ours[1:] + _empty_instance_files(pvr_dir):
try:
os.remove(filename)
LOG('remove_extra_instances: removed {}'.format(filename))
removed = True
except OSError as e:
LOG('remove_extra_instances: {}: {}'.format(filename, e))
return removed
def ensure_channel_numbers(marker):
"""Makes Kodi show the channel numbers of the list (Movistar's dial
numbers) instead of 1, 2, 3...
Kodi only lets "use channel numbers from backend" be read or changed while
it applies: with exactly one PVR instance loaded, or with several and the
"always" setting on. Radios de Vigo adds an instance of IPTV Simple and
some boxes carry a leftover empty one, so the setting can stop applying
after it was turned on. This is called now and then and puts it right:
- hidden: turns "always" on, which brings the stored value back;
- off and never turned on by this add-on (no marker file): turns it on;
- off after this add-on turned it on once: the user's choice, left alone.
It never turns anything off, so it can't undo what Radios de Vigo needs.
Returns True when Kodi is using the numbers of the list."""
import xbmc
def rpc(method, **params):
try:
return json.loads(xbmc.executeJSONRPC(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params})))
except ValueError:
return {}
def numbers():
return (rpc('Settings.GetSettingValue', setting='pvrmanager.usebackendchannelnumbers').get('result') or {}).get('value')
value = numbers()
if value is None:
rpc('Settings.SetSettingValue', setting='pvrmanager.usebackendchannelnumbersalways', value=True)
value = numbers()
if value is None: return False # IPTV Simple isn't loaded right now: try later
if value is False and not os.path.exists(marker):
rpc('Settings.SetSettingValue', setting='pvrmanager.usebackendchannelnumbers', value=True)
value = numbers()
if value is True and not os.path.exists(marker):
write_if_changed(marker, '1')
LOG('ensure_channel_numbers: on')
return value is True
def extra_channels_xmltv(session):
"""Returns the <channel> and <programme> elements for the extra channels,
taken from a public XMLTV guide and renamed to their tvg-id."""
+41 -8
View File
@@ -40,6 +40,7 @@ class Movistar(object):
add_extra_info = True
hide_unplayable = True
add_extra_channels = True
add_web_channels = True
#dplayer = 'webplayer'
#device_code = 'WP_OTT'
#manufacturer = 'Firefox'
@@ -548,6 +549,10 @@ class Movistar(object):
groups.setdefault(p['Canal']['CodCadenaTv'], []).append(p)
data = list(groups.values())
# The channels of the new servers aren't in the amazon.tv guide
if self.add_web_channels and not channels:
data = data + kodimov.web_only_epg(self, date, duration)
for ch in data:
if not ch: continue
id = ch[0]['Canal']['CodCadenaTv']
@@ -564,6 +569,13 @@ class Movistar(object):
if 'TituloHorLinea2' in p:
pr['desc2'] = p['TituloHorLinea2']
pr['show_id'] = p['ShowId']
# Synopsis, genre, year, age rating and picture come with the guide
pr['plot'] = p.get('Resena') or ''
pr['genre'] = p.get('GeneroComAntena') or ''
pr['year'] = p.get('Anno') or ''
pr['age_rating'] = (p.get('NivelMoral') or {}).get('Id') or ''
images = {i.get('id'): i.get('uri') for i in (p.get('Imagenes') or [])}
pr['image'] = images.get('horizontal') or images.get('default') or ''
if 'SerialId' in p:
pr['serie_id'] = p['SerialId']
pr['id'] = p['Id']
@@ -674,6 +686,10 @@ class Movistar(object):
if not 'error' in data:
self.cache.save_file(cache_filename, json.dumps(data, ensure_ascii=False))
# LALIGA, DAZN LALIGA and Liga de Campeones are only in the web player's list
if self.add_web_channels and isinstance(data, list):
data = data + kodimov.web_only_channels(self, data, profile, demarcation)
res = []
if 'error' in data: return res
@@ -702,6 +718,7 @@ class Movistar(object):
t['info']['playcount'] = 1 # Set as watched
t['uid'] = c.get('Uid')
t['watermark'] = bool(c.get('QMWatermark'))
t['new_api'] = bool(c.get('new_api'))
if False and 'links' in c:
for l in c['links']:
if l['rel'] == 'watermarks':
@@ -720,8 +737,11 @@ class Movistar(object):
res.append(t)
if any(t['new_api'] for t in res):
# Back in dial order (a stable sort: the rest of the list doesn't move)
res.sort(key=kodimov.dial_number)
if self.hide_unplayable:
res = [t for t in res if kodimov.is_playable(t)]
res = kodimov.playable_channels(self, res)
return res
def get_channels_with_epg(self):
@@ -1379,7 +1399,8 @@ class Movistar(object):
shutil.copyfile(self.cache.config_directory + 'credentials.json', filename)
def delete_session_files(self):
for f in ['access_token.conf', 'account.json', 'device_id.conf', 'devices.json', 'profile_id.conf', 'tokens.json', 'channels2.json', 'channels_UHD.json', 'channels_HD.json', 'epg2.json', 'epg_UHD.json', 'epg_HD.json', 'cdn.conf']:
for f in ['access_token.conf', 'account.json', 'device_id.conf', 'devices.json', 'profile_id.conf', 'tokens.json', 'channels2.json', 'channels_UHD.json', 'channels_HD.json', 'epg2.json', 'epg_UHD.json', 'epg_HD.json', 'cdn.conf',
'new_api.json', 'new_api.stamp', 'cdn_new.conf']:
self.cache.remove_file(f)
def get_profile_image_url(self, img_id):
@@ -1413,6 +1434,7 @@ class Movistar(object):
t['logo'] = c['art']['icon']
t['preset'] = c['dial']
args = urlencode({'action': 'play', 'stype': 'tv', 'id': c['id'], 'url': c['url'], 'session_request': c['session_request']})
if c.get('new_api'): args += '&new_api=1'
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args
if 'cas_id' in c: t['cas_id'] = c['cas_id']
res.append(t)
@@ -1447,6 +1469,7 @@ class Movistar(object):
for channel in channels:
id = channel['id']
if not id in epg: continue
new_api = '&new_api=1' if channel.get('new_api') else ''
res[id] = []
for e in epg[id]:
t = {}
@@ -1454,13 +1477,16 @@ class Movistar(object):
t['subtitle'] = e['desc2']
t['start'] = datetime.utcfromtimestamp(e['start']/1000).strftime('%Y-%m-%dT%H:%M:%SZ')
t['stop'] = datetime.utcfromtimestamp(e['end']/1000).strftime('%Y-%m-%dT%H:%M:%SZ')
for key, field in (('description', 'plot'), ('image', 'image'), ('genre', 'genre'),
('year', 'year'), ('age_rating', 'age_rating')):
if e.get(field): t[key] = e[field]
if True:
program_id = str(e['show_id'])
url = 'https://grmovistar.imagenio.telefonica.net/asfe/rest/tvMediaURLs?tvProgram.id='+ program_id +'&svc=startover'
session_request = '{"contentID":' + program_id +',"streamType":"CUTV"}'
args = urlencode({'action': 'play', 'stype': 'u7d', 'id': program_id, 'url': url, 'session_request': session_request})
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args + new_api
if False and self.add_extra_info and id in ['HOLLYW', 'TCM', 'AMC', 'MV3', 'MV2', 'CPSER', 'FOXGE', 'TNT']:
i = {'id': e['id'], 'show_id': e['show_id']}
@@ -1523,8 +1549,9 @@ class Movistar(object):
if only_subscribed and not ch['subscribed']: continue
for e in epg[ch['id']]:
#LOG('* e:{}'.format(e))
start = datetime.strptime(e['start'], "%Y-%m-%dT%H:%M:%SZ").strftime("%Y%m%d%H%M%S +0000")
stop = datetime.strptime(e['stop'], "%Y-%m-%dT%H:%M:%SZ").strftime("%Y%m%d%H%M%S +0000")
# 2026-11-10T08:00:00Z -> 20261110080000 +0000, without strptime: inside
# Kodi it fails at random ("'NoneType' object is not callable")
start, stop = [re.sub(r'\D', '', e[k]) + ' +0000' for k in ('start', 'stop')]
url = e.get('stream', None)
if url:
url = url.replace('&', '&amp;')
@@ -1533,8 +1560,6 @@ class Movistar(object):
'>\n' +
' <title>{}</title>\n'.format(html_escape(e['title'])) +
' <sub-title>{}</sub-title>\n'.format(html_escape(e['subtitle'])))
if 'image' in e:
res.append(' <icon src="{}"/>\n'.format(e['image']))
if 'description' in e:
res.append(' <desc>{}</desc>\n'.format(html_escape(e['description'])))
if 'credits' in e and len(e['credits']) > 0:
@@ -1545,11 +1570,19 @@ class Movistar(object):
elif c['type'] == 'actor':
res.append(' <actor>{}</actor>\n'.format(c['name']))
res.append(' </credits>\n');
if 'year' in e:
res.append(' <date>{}</date>\n'.format(html_escape(e['year'])))
if 'genre' in e:
res.append(' <category>{}</category>\n'.format(html_escape(e['genre'])))
if 'image' in e:
res.append(' <icon src="{}"/>\n'.format(html_escape(e['image'])))
if 'age_rating' in e:
res.append(' <rating system="ES"><value>{}</value></rating>\n'.format(html_escape(e['age_rating'])))
res.append('</programme>\n')
if self.add_extra_channels:
res.extend(kodimov.extra_channels_xmltv(self.net.session))
res.append('</tv>\n')
kodimov.write_if_changed(filename, ''.join(res))
return kodimov.write_if_changed(filename, ''.join(res))
def save_credentials(self, username, password):
from .b64 import encode_base64
+61 -9
View File
@@ -123,7 +123,19 @@ def play(params):
#headers += '&Host=wv-ottlic-f3.imagenio.telefonica.net'
manifest_headers = 'User-Agent=' + useragent
if True: #stype in ['tv', 'u7d', 'rec']:
if params.get('new_api') == '1':
# LALIGA, DAZN LALIGA, Liga de Campeones: their streams only open with a
# CDN token from the new servers. The player keeps the token it starts
# with, so it must have hours left.
from . import kodimov
try:
cdn_token = kodimov.new_api_cdn_token(m, min_life=3 * 3600)
except Exception as e:
LOG('new_api_cdn_token failed: {}'.format(e))
show_notification(addon.getLocalizedString(30510).format(e))
return
manifest_headers += '&x-tcdn-token=' + cdn_token
elif True: #stype in ['tv', 'u7d', 'rec']:
cdn_token = m.cache.load('cdn.conf', 60)
if not cdn_token:
cdn_token = m.get_cdntoken()
@@ -177,6 +189,14 @@ def play(params):
play_item.setProperty('inputstream.adaptive.manifest_type', 'mpd')
#play_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
if addon.getSettingBool('best_quality'):
# Left to itself, InputStream Adaptive starts these streams at 576p and
# never gets to the 720p/1080p versions (they use a different key)
best = '4K' if addon.getSettingBool('uhd') else '1080p'
play_item.setProperty('inputstream.adaptive.stream_selection_type', 'fixed-res')
play_item.setProperty('inputstream.adaptive.chooser_resolution_max', best)
play_item.setProperty('inputstream.adaptive.chooser_resolution_secure_max', best)
play_item.setProperty('inputstream.adaptive.stream_headers', manifest_headers)
play_item.setProperty('inputstream.adaptive.manifest_headers', manifest_headers)
@@ -397,6 +417,7 @@ def add_videos(category, ctype, videos, ref=None, url_next=None, url_prev=None,
list_item.addContextMenuItems(actions)
url = get_url(action='play', id=t['id'], url=t['url'], session_request=t['session_request'], stype=t['stream_type'])
if t.get('new_api'): url += '&new_api=1'
if 'show_id' in t: url += '&show_id={}'.format(t['show_id'])
if 'uid' in t: url += '&uid={}'.format(t['uid'])
xbmcplugin.addDirectoryItem(_handle, url, list_item, False)
@@ -663,11 +684,8 @@ def login():
show_notification(addon.getLocalizedString(30166)) # Failed
def network_login():
# The account number and the device are found/registered automatically.
account_number = addon.getSetting('account_number')
if not account_number and not m.cache.load_file('account.json') and not m.cache.load_file('tokens.json'):
account_number = input_window(addon.getLocalizedString(30502)) # Account number
if not account_number: return
addon.setSetting('account_number', account_number)
# Keeps device_id.conf: clearing the session would register a new device.
if m.try_network_login(account_number, force=True):
show_notification(addon.getLocalizedString(30503), xbmcgui.NOTIFICATION_INFO) # Logged in
@@ -752,6 +770,7 @@ def iptv(params):
def create_iptv_settings():
from .iptv import save_iptv_settings
from . import kodimov
show_notification(addon.getLocalizedString(30319), xbmcgui.NOTIFICATION_INFO)
output_file = 'instance-settings-91.xml' if kodi_version > 19 else 'settings.xml'
epg_url = None
@@ -762,11 +781,33 @@ def create_iptv_settings():
pvr_dir = translatePath(pvr_addon.getAddonInfo('profile'))
LOG('pvr_dir: {}'.format(pvr_dir))
filename = os.path.join(pvr_dir, output_file)
if kodi_version > 19:
# Rewrite the instance that already reads this add-on's list, if any:
# a second one would show every channel twice
existing = kodimov.iptv_instance_files(pvr_dir, 'kodimovplus')
if existing: filename = existing[0]
if os.path.exists(filename):
res = xbmcgui.Dialog().yesno(addon.getLocalizedString(30322), addon.getLocalizedString(30323))
if res == False: return
save_iptv_settings(filename, 'KodiMov+', 'kodimovplus', epg_url)
# Write the channel list and the programme guide first: if IPTV Simple
# starts before epg.xml exists, Kodi stores an empty guide for every
# channel and doesn't ask again for hours.
export_epg_now()
if not os.path.exists(os.path.join(profile_dir, 'epg.xml')):
show_notification(addon.getLocalizedString(30509)) # Log in first
return
save_iptv_settings(filename, 'KodiMov+', 'kodimovplus', epg_url)
# IPTV Simple now reads epg.xml, so the service must keep it up to date.
addon.setSettingBool('auto_export_epg', True)
# Restart IPTV Simple so it loads the new instance and channel list.
kodimov.restart_iptv_simple()
# Show Movistar's own channel numbers instead of 1, 2, 3... Kodi only
# accepts it once IPTV Simple is back; the service keeps trying otherwise.
numbers_set = os.path.join(profile_dir, 'numbers-set')
if os.path.exists(numbers_set): os.remove(numbers_set)
for _ in range(5):
if kodimov.ensure_channel_numbers(numbers_set): break
xbmc.sleep(2000)
except:
show_notification(addon.getLocalizedString(30324))
@@ -776,7 +817,7 @@ def export_channels_now():
if m.export_channels_to_m3u8(channels_filename, addon.getSettingBool('only_subscribed')):
LOG('export_channels_now: channel list changed')
def export_epg_now(quiet=False):
def export_epg_now(quiet=False, reload_guide=False):
# quiet: background refresh from the service, no pop-ups while watching TV
def notify(message):
if not quiet: show_notification(message, xbmcgui.NOTIFICATION_INFO)
@@ -793,7 +834,14 @@ def export_epg_now(quiet=False):
if True: #not addon.getSettingBool('use_external_epg'):
notify(addon.getLocalizedString(30311))
m.export_epg_to_xml(epg_filename, addon.getSettingInt('export_days'), report_progress, only_subscribed)
changed = m.export_epg_to_xml(epg_filename, addon.getSettingInt('export_days'), report_progress, only_subscribed)
if reload_guide == 'force' or (changed and reload_guide):
from . import kodimov
try:
pvr_dir = translatePath(xbmcaddon.Addon('pvr.iptvsimple').getAddonInfo('profile'))
except RuntimeError: # IPTV Simple not installed or disabled
pvr_dir = None
if pvr_dir: kodimov.reload_iptv_guide(pvr_dir, 'kodimovplus')
notify(addon.getLocalizedString(30313))
@@ -844,6 +892,10 @@ def router(paramstring):
network_login()
elif params['action'] == 'export_channels_now':
export_channels_now()
elif params['action'] == 'new_api_keep_warm':
if m.logged:
from . import kodimov
kodimov.new_api_keep_warm(m)
elif params['action'] == 'export_key':
export_key()
elif params['action'] == 'export_credentials':
@@ -889,7 +941,7 @@ def router(paramstring):
elif params['action'] == 'create_iptv_settings':
create_iptv_settings()
elif params['action'] == 'export_epg_now':
export_epg_now(quiet=params.get('quiet') == '1')
export_epg_now(quiet=params.get('quiet') == '1', reload_guide=params.get('reload_guide'))
elif params['action'] == 'to_wishlist':
to_wishlist(params)
elif 'iptv' in params['action']:
+20 -3
View File
@@ -241,12 +241,17 @@ class RequestHandler(BaseHTTPRequestHandler):
LOG('license response: {}'.format(license_data))
d = try_load_json(license_data)
if d and 'errorCode' in d:
from .gui import show_notification
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
# KodiMov+: the error is shown only when the retry below
# doesn't get a licence. The first request for a LALIGA /
# Liga de Campeones channel is refused with 4027 every time,
# and passes once the device is registered again (a pause
# and a plain retry don't help; tested 2026-11-13).
if d['errorCode'] == 4027:
if not reregister_needed and addon.getSettingBool('reregister'):
reregister_needed = True
continue
from .gui import show_notification
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
else:
LOG('license response: {}'.format(encode_base64(license_data)))
break
@@ -269,6 +274,10 @@ PORT = 57031
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
# KodiMov+: must be set before the server binds (setting it on the
# instance afterwards did nothing): connections of the previous proxy
# left in TIME_WAIT would otherwise keep the port taken
allow_reuse_address = True
class Proxy(object):
started = False
@@ -276,8 +285,8 @@ class Proxy(object):
def check_port(self, port=0, default=False):
try:
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind((HOST, port))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, port))
return s.getsockname()[1]
except:
return default
@@ -287,6 +296,14 @@ class Proxy(object):
return
port = self.check_port(PORT)
# KodiMov+: when the add-on updates, the previous service may still
# hold the port for a few seconds. Wait for it: a stream that is
# playing keeps asking this port for its manifest, and dies if the
# proxy moves to another one.
for _ in range(20):
if port: break
time.sleep(0.5)
port = self.check_port(PORT)
if not port:
port = self.check_port(0)
LOG('port: {}'.format(port))
+3 -1
View File
@@ -10,6 +10,7 @@
<setting label="30020" type="bool" id="channels_with_epg" default="false"/>
<setting label="30021" type="bool" id="add_extra_info" default="false"/>
<setting label="30033" type="bool" id="uhd" default="false"/>
<setting label="30511" type="bool" id="best_quality" default="false"/>
<setting label="30042" type="number" id="expdays" default="30"/>
<setting id="use_ttml2ssa" type="bool" label="$ADDON[script.module.ttml2ssa 32202]" default="false"/>
<setting id="ssa_settings" type="action" label="$ADDON[script.module.ttml2ssa 32201]" option="close" action="Addon.OpenSettings(script.module.ttml2ssa)" visible="eq(-1,true)" subsetting="true"/>
@@ -62,7 +63,8 @@
-->
<setting label="30508" type="bool" id="export_channels" default="true"/>
<setting label="30305" type="bool" id="export_epg" default="true"/>
<!-- "auto_export_epg", not "export_epg": kodimov.1 shipped "export_epg" off and Kodi kept that value -->
<setting label="30305" type="bool" id="auto_export_epg" default="true"/>
<setting label="30315" type="number" id="export_days" default="2" enable="eq(-1,true)"/>
<setting type="sep"/>
<setting label="30312" type="action" id="export_epg_now" action="RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now)" option="close"/>
+57 -26
View File
@@ -10,27 +10,14 @@ import xbmcgui
from resources.lib.proxy import Proxy
from resources.lib.addon import *
from resources.lib.log import LOG
from resources.lib import kodimov
def export_epg():
from datetime import datetime, timedelta
def needs_update(filename):
if os.path.exists(filename):
file_modification_time = datetime.fromtimestamp(os.path.getmtime(filename))
return datetime.now() - file_modification_time > timedelta(hours=6)
return True
channels_filename = os.path.join(profile_dir, 'channels.m3u8')
epg_filename = os.path.join(profile_dir, 'epg.xml')
LOG('channels filename: {}'.format(channels_filename))
LOG('epg filename: {}'.format(epg_filename))
channels_needs_update = needs_update(channels_filename)
epg_needs_update = needs_update(epg_filename)
LOG('channels_needs_update: {} epg_needs_update: {}'.format(channels_needs_update, epg_needs_update))
if channels_needs_update or epg_needs_update:
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now)')
def file_age(filename):
"""Seconds since the file was last written (or found unchanged)."""
try:
return time.time() - os.path.getmtime(filename)
except OSError:
return float('inf')
if __name__ == '__main__':
LOG('Service started')
@@ -39,17 +26,61 @@ if __name__ == '__main__':
LOG('proxy_address: {}'.format(proxy.proxy_address))
# Refreshes the TV guide channel list (new channels, new numbers) and the
# programme guide a minute after Kodi starts and then every 6 hours. Opening the add-on also renews the 10-day Movistar login.
next_export = time.time() + 60
# programme guide a minute after Kodi starts and then every 6 hours; a
# refresh that failed is tried again half an hour later. Opening the add-on
# also renews the 10-day Movistar login.
started = time.time()
next_check = started + 60
max_age = 0 # always refresh once after Kodi starts
new_api_retries = 6
try:
pvr_dir = translatePath(xbmcaddon.Addon('pvr.iptvsimple').getAddonInfo('profile'))
restart_iptv = kodimov.remove_extra_instances(pvr_dir, 'kodimovplus')
restart_iptv = kodimov.set_iptv_refresh(pvr_dir, 'kodimovplus') or restart_iptv
except RuntimeError: # IPTV Simple not installed or disabled
pvr_dir = None
restart_iptv = False
# A changed instance file needs IPTV Simple restarted. The guide reload
# after start (below) restarts it anyway, so that one is left to do it;
# this is only the fallback, 10 minutes later, if it didn't happen.
restart_after = started + 10 * 60
monitor = xbmc.Monitor()
while not monitor.abortRequested():
if monitor.waitForAbort(1):
break
if time.time() >= next_export:
next_export = time.time() + 6 * 3600
if addon.getSettingBool('export_epg'): # channels + programme guide
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now&quiet=1)')
if restart_iptv and kodimov.last_iptv_restart() > started:
restart_iptv = False
if restart_iptv and time.time() >= restart_after and kodimov.is_idle():
restart_iptv = False
kodimov.restart_iptv_simple()
if time.time() >= next_check:
next_check = time.time() + 30 * 60
exporting = False
# Movistar's channel numbers in Kodi's TV section, also when another
# add-on (Radios de Vigo) adds its own IPTV Simple instance
if pvr_dir and kodimov.has_iptv_instance(pvr_dir, 'kodimovplus'):
kodimov.ensure_channel_numbers(os.path.join(profile_dir, 'numbers-set'))
age_limit, max_age = max_age, 6 * 3600 - 120
# After Kodi starts (or this add-on updates) also get the new guide into Kodi now
reload_guide = '&reload_guide=1' if age_limit == 0 else ''
if restart_iptv: reload_guide = '&reload_guide=force' # also for the changed instance files
# The new servers failed (they at times refuse everything for minutes):
# the list lacks LALIGA, DAZN LALIGA, Liga de Campeones. Try again in
# half an hour, a few times, instead of in 6 hours.
if new_api_retries and kodimov.new_api_failed(profile_dir):
new_api_retries -= 1
age_limit = 0
reload_guide = reload_guide or '&reload_guide=1'
if addon.getSettingBool('auto_export_epg'): # channels + programme guide
if file_age(os.path.join(profile_dir, 'epg.xml')) > age_limit:
exporting = True
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now&quiet=1' + reload_guide + ')')
elif addon.getSettingBool('export_channels'):
if file_age(os.path.join(profile_dir, 'channels.m3u8')) > age_limit:
exporting = True
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_channels_now)')
if not exporting:
# Keeps the pass for the LALIGA / Liga de Campeones streams fresh
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=new_api_keep_warm)')
proxy.stop()