Compare commits

..
23 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
xockoandClaude Opus 5.5 119002fbbf 1.0.0+kodimov.2: programme guide for every channel, TeleVigo on 15
- The amazon.tv guide that upstream 1.0.0 switched to is a flat list of
  programmes; group it by channel (the guide export crashed with KeyError)
- Add TVG2/TeleVigo listings from the EPG_dobleM guide to epg.xml
- Export channels + programme guide every 6 hours, by default, without
  on-screen notifications
- TeleVigo moves to 15, near the generalist channels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:01:37 +01:00
xockoandClaude Opus 5.5 235f7a6cab Sort the TV guide list by channel number; direct TeleVigo stream
Kodi lists channels in file order, so TVG2 and TeleVigo showed up last.
TeleVigo's master playlist points to an ad server URL with unfilled
placeholders that Kodi can't open; use the direct playlist instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:46:10 +01:00
xockoandClaude Opus 5.5 066a203e9e KodiMov+ 1.0.0+kodimov.1: fork of Movistar+ 1.0.0
- New id plugin.video.kodimovplus, name KodiMov+, proxy port 57031
- Takes over the login of plugin.video.movistarplus on first run
- Password-less login from the Movistar home network (autologin token
  traded for a classic token via the new servers' initData)
- Hides the channels that can't play (LALIGA, Liga de Campeones)
- Adds TVG2 and TeleVigo to the TV guide list
- Re-exports the TV guide channel list every 6 hours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:32:59 +01:00
Paco8 ccc17b4e5c Version 1.0.0 2026-11-05 09:55:25 +01:00
Paco8 6511fa80b0 Version 0.11.9 2026-10-08 23:50:34 +02:00
Paco8 0739cca0a9 Version 0.11.8 2026-07-02 10:01:50 +02:00
Paco8 930b238204 Version 0.11.6 2026-04-15 12:25:33 +02:00
Paco8 6aab76c47d Version 0.11.5 2026-03-25 23:33:08 +01:00
Paco8 bd0853e126 Version 0.11.2 2025-07-01 12:57:07 +02:00
Paco8 4f738f61c7 Version 0.11.1 2025-05-21 16:31:06 +02:00
39 changed files with 1363 additions and 167 deletions

No files matched your search

+1 -1
View File
@@ -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:
+26
View File
@@ -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.
---
![GitHub release (latest by date)](https://img.shields.io/github/v/release/Paco8/plugin.video.movistarplus) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/Paco8/plugin.video.movistarplus)
![GitHub all releases](https://img.shields.io/github/downloads/Paco8/plugin.video.movistarplus/total) ![GitHub all releases](https://img.shields.io/github/downloads/Paco8/plugin.video.movistarplus/total)
Binary file not shown.
+24 -10
View File
@@ -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.
+2 -1
View File
@@ -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"
} }
+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="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">&amp;start_time={utc}&amp;end_time={utcend}</setting>
<setting id="allChannelsCatchupMode">2</setting>''' <setting id="allChannelsCatchupMode">2</setting>'''
content += ''' content += '''
+701
View File
@@ -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
View File
@@ -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('&', '&amp;') url = url.replace('&', '&amp;')
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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()