Compare commits

..
10 Commits
Author SHA1 Message Date
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
38 changed files with 632 additions and 116 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
VERSION = $(shell ./get_version.py)
OUTPUT_DIR = plugin.video.movistarplus-$(VERSION)
OUTPUT_DIR = plugin.video.kodimovplus-$(VERSION)
OUTPUT_FILE = $(OUTPUT_DIR).zip
install:
+16
View File
@@ -1,3 +1,19 @@
# 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 password. The home-network login gives a token for Movistar's new servers, which is traded once for a classic token; the classic servers renew it every 10 days. It needs the account number (taken from an existing login, or set in the settings) and an already registered device.
- **Takes over the login of plugin.video.movistarplus** on first run (same registered device, so no new device is added to the account).
- **Hides channels it can't play**: Movistar marks football and premium sports with `QMWatermark`; LALIGA and Liga de Campeones refuse their manifest, while M+ Deportes, DAZN and DAZN Baloncesto play and are kept (`resources/lib/kodimov.py`).
- **Extra free channels** in the TV guide list: TVG2 (10) and TeleVigo (15).
- **The TV guide stays current**: channel list and programme guide re-exported every 6 hours, numbered with Movistar's own dial numbers. Listings come from Movistar (with catch-up links) for its channels and from the public EPG_dobleM guide for the extras.
- Fixes the programme guide export of upstream 1.0.0 (the amazon.tv guide is a flat list).
- Its own proxy port (57031), so it can run next to the original.
---
![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)
Binary file not shown.
+12 -10
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.movistarplus"
name="Movistar+"
version="0.11.0"
provider-name="Paco8">
<addon id="plugin.video.kodimovplus"
name="KodiMov+"
version="1.0.0+kodimov.2"
provider-name="Paco8, xocko">
<requires>
<!--- <import addon="xbmc.python" version="2.25.0"/> -->
<import addon="script.module.requests"/>
@@ -17,16 +17,18 @@
<extension point="xbmc.service" library="service.py" />
<extension point="xbmc.addon.metadata">
<summary lang="en_GB">Movistar+</summary>
<description lang="en_GB">Movistar+</description>
<description lang="en_GB">Watch live channels, recordings and video on demand content from Movistar+ in Kodi. Requires a 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>
<summary lang="en_GB">Movistar+ for Kodi, with automatic login from the Movistar home network</summary>
<summary lang="es_ES">Movistar+ para Kodi, con inicio de sesión automático desde la red de Movistar</summary>
<description lang="en_GB">Fork of Paco8's Movistar+ add-on (plugin.video.movistarplus). Logs in by itself from the Movistar home network, keeps the channel list and numbers up to date, hides the channels that need stronger DRM than Kodi has, adds free Galician channels (TVG2, TeleVigo) and writes a programme guide for every channel. Requires a Movistar+ subscription.</description>
<description lang="es_ES">Fork del addon Movistar+ de Paco8 (plugin.video.movistarplus). Inicia sesión solo desde la red de Movistar, mantiene la lista y los números de los canales al día, oculta los canales que necesitan una protección que Kodi no tiene añade canales gallegos gratuitos (TVG2, TeleVigo) y genera la guía de programación de todos los canales. Se necesita estar abonado a Movistar+.</description>
<news>1.0.0+kodimov.2: programme guide for every channel (Movistar + TVG2/TeleVigo), TeleVigo on 15, silent background refresh.
1.0.0+kodimov.1: first KodiMov+ release, based on Movistar+ 1.0.0.</news>
<disclaimer lang="en_GB">This addon is not officially commissioned/supported by Movistar+. All product names, logos, and trademarks mentioned in this project are property of their respective owners.</disclaimer>
<disclaimer lang="es_ES">Este addon no ha sido realizado ni apoyado por Movistar+. Todos los nombres de productos, logotipos y marcas registradas mencionados en este proyecto son propiedad de sus respectivos dueños.</disclaimer>
<platform>all</platform>
<license>LGPL-2.1-or-later</license>
<website></website>
<source></source>
<website>https://git.anosanube.ovh/xocko/kodimovplus</website>
<source>https://git.anosanube.ovh/xocko/kodimovplus</source>
<assets>
<icon>icon.png</icon>
<fanart>resources/fanart.png</fanart>
Binary file not shown.
@@ -621,3 +621,35 @@ msgstr ""
msgctxt "#30451"
msgid "If you find this addon useful you can buy the developer a coffee:"
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 (LALIGA, Liga de Campeones)"
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 ""
@@ -585,6 +585,38 @@ msgctxt "#30451"
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é:"
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 (LALIGA, Liga de Campeones)"
msgstr "Ocultar los canales que no se pueden reproducir (LALIGA, Liga de Campeones)"
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 "#30040"
#~ msgid "Use only for these languages (e.g.: es en pt):"
#~ msgstr "Usar solo para estos idiomas (ej: es en pt):"
Binary file not shown.
Binary file not shown.
Binary file not shown.
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",
"tearDownStream": "https://alkasvaspub.imagenio.telefonica.net/asvas/ccs/{PID}/{deviceCode}/{PLAYREADYID}/Session/{SessionID}",
"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"
}
+202
View File
@@ -0,0 +1,202 @@
# 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
# - 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.
# The LALIGA and Liga de Campeones ones refuse their manifest to this add-on;
# these play fine (tested 2026-11-07 on a Raspberry Pi 5, LibreELEC 12.2).
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'
def is_playable(channel):
return not channel.get('watermark') or channel['id'] in WATERMARKED_BUT_PLAYABLE
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 network_login(m, account_number):
"""Password-less login from the Movistar home network.
1. autologin (only reachable inside Movistar's network) gives a 5-minute
token for the new servers;
2. their initData trades it for a legacyAccessToken, which the classic
servers this add-on uses accept (and renew for 10 days at a time).
Returns (success, reason)."""
try:
from urllib.parse import urlsplit
except ImportError:
from urlparse import urlsplit
from .signature import get_auth
device_id = (m.cache.load_file('device_id.conf') or '').strip().strip('"')
if not account_number: return False, 'no account number'
if not device_id: return False, 'no registered device'
headers = m.net.headers.copy()
headers['Content-Type'] = 'application/json'
url = AUTOLOGIN_URL.format(device=device_id, ts=int(time.time() * 1000))
try:
r = m.net.session.get(url, headers=headers, allow_redirects=False, timeout=(4, 10))
location = r.headers.get('Location', '')
if r.status_code in (301, 302, 303, 307, 308) and location:
if not urlsplit(location).netloc.endswith('.telefonicaservices.com'):
return False, 'unexpected redirect'
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')
except Exception as e:
LOG('network_login: autologin failed: {}'.format(e))
return False, 'not on the Movistar network'
if not token: return False, 'autologin refused'
body = {'accountNumber': account_number, 'sessionUserProfile': 0, 'isKidProfile': False,
'deviceId': device_id, 'streamMiscellanea': 'HTTPS', 'deviceManufacturerProduct': 'Firefox',
'streamDRM': 'Widevine', 'streamFormat': 'DASH'}
data = None
for attempt in range(4):
h = headers.copy()
h['Authorization'] = get_auth('post', SOTER_INITDATA_URL, token)
try:
r = m.net.session.post(SOTER_INITDATA_URL, data=json.dumps(body), headers=h, timeout=(4, 15))
data = r.json()
break
except Exception:
# The new servers sometimes answer with an HTML error page; retry.
LOG('network_login: initData attempt {} failed ({})'.format(attempt + 1, getattr(r, 'status_code', '?')))
time.sleep(3)
legacy = data.get('legacyAccessToken') if isinstance(data, dict) else None
props = m.get_token_properties(legacy) if legacy else None
if not props or props.get('iss') != CLASSIC_ISSUER or props.get('exp', 0) < time.time():
return False, 'initData refused'
m.save_key_file({'access_token': legacy, 'token_type': 'bearer'})
m.cache.save_file('access_token.conf', legacy)
LOG('network_login: OK')
return True, ''
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: 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 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
+115 -17
View File
@@ -22,6 +22,7 @@ from .network import Network
from .cache import Cache
from .timeconv import *
from .useragent import useragent
from . import kodimov
class Movistar(object):
account = {'username': '', 'password': '',
@@ -37,6 +38,8 @@ class Movistar(object):
'demarcation': 0}
add_extra_info = True
hide_unplayable = True
add_extra_channels = True
#dplayer = 'webplayer'
#device_code = 'WP_OTT'
#manufacturer = 'Firefox'
@@ -45,9 +48,14 @@ class Movistar(object):
manufacturer = 'LG'
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.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
headers = {
@@ -111,6 +119,9 @@ class Movistar(object):
#LOG('access_token: {}'.format(self.account['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
# Account
@@ -209,6 +220,21 @@ class Movistar(object):
data = self.cache.load_file('searchs.json')
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):
data = {"accountNumber": self.account['id'],
"userProfile": self.account['profile_id'],
@@ -479,7 +505,9 @@ class Movistar(object):
def load_epg_data(self, date_str, duration=2, channels=''):
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'
#LOG(url)
data = self.net.load_data(url)
@@ -512,7 +540,16 @@ class Movistar(object):
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())
for ch in data:
if not ch: continue
id = ch[0]['Canal']['CodCadenaTv']
if not id in epg: epg[id] = []
for p in ch:
@@ -628,7 +665,9 @@ class Movistar(object):
if content:
data = json.loads(content)
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'
#LOG(url)
data = self.net.load_data(url)
@@ -661,6 +700,12 @@ class Movistar(object):
t['session_request'] = '{"contentID":"'+ t['id'] +'", "streamType":"CHN"}'
t['subscribed'] = self.is_subscribed_channel(c.get('tvProducts', []))
t['info']['playcount'] = 1 # Set as watched
t['uid'] = c.get('Uid')
t['watermark'] = bool(c.get('QMWatermark'))
if False and 'links' in c:
for l in c['links']:
if l['rel'] == 'watermarks':
t['watermark_url'] = l['href']
# epg
#if add_epg_info:
# program = c['Pases'][0]
@@ -675,6 +720,8 @@ class Movistar(object):
res.append(t)
if self.hide_unplayable:
res = [t for t in res if kodimov.is_playable(t)]
return res
def get_channels_with_epg(self):
@@ -1035,6 +1082,25 @@ class Movistar(object):
t['stream_type'] = 'u7d'
t['session_request'] = '{"contentID":' + str(t['id']) + ', "streamType":"CUTV"}'
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:
da = d['DatosAccesoAnonimo']
t['video_format'] = da.get('FormatoVideo')
@@ -1139,7 +1205,9 @@ class Movistar(object):
def get_subtitles(self, 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>'
matches = re.findall(rx, content, flags=re.MULTILINE | re.DOTALL)
res = []
@@ -1158,11 +1226,13 @@ class Movistar(object):
#LOG('output_dir: {}'.format(output_dir))
if not os.path.exists(output_dir):
os.makedirs(output_dir)
headers = self.net.headers.copy()
headers['x-tcdn-token'] = self.get_cdntoken()
res = []
for s in sublist:
filename = output_dir + os.sep + s['lang'] + '.ttml'
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:
handle.write(content)
res.append(filename)
@@ -1309,7 +1379,7 @@ class Movistar(object):
shutil.copyfile(self.cache.config_directory + 'credentials.json', filename)
def delete_session_files(self):
for f in ['access_token.conf', 'account.json', 'device_id.conf', 'devices.json', 'profile_id.conf', 'tokens.json', 'channels2.json', 'channels_UHD.json', 'channels_HD.json', 'epg2.json', 'epg_UHD.json', 'epg_HD.json']:
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']:
self.cache.remove_file(f)
def get_profile_image_url(self, img_id):
@@ -1343,7 +1413,7 @@ class Movistar(object):
t['logo'] = c['art']['icon']
t['preset'] = c['dial']
args = urlencode({'action': 'play', 'stype': 'tv', 'id': c['id'], 'url': c['url'], 'session_request': c['session_request']})
t['stream'] = 'plugin://plugin.video.movistarplus/?' + args
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args
if 'cas_id' in c: t['cas_id'] = c['cas_id']
res.append(t)
return res
@@ -1355,12 +1425,15 @@ class Movistar(object):
if only_subscribed and not t['subscribed']: continue
url = t['stream']
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(
name=t['name'], id=t['id'], logo=t['logo'], preset=t['preset'], stream=url)
items.append(item)
res = '#EXTM3U\n## Movistar+\n{}'.format(''.join(items))
with io.open(filename, 'w', encoding='utf-8', newline='') as handle:
handle.write(res)
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, catchup='catchup="default" catchup-source="{catchup-id}" catchup-days="7"')
items.append((int(t['preset'] or 0), item))
if self.add_extra_channels:
items.extend(kodimov.extra_channels_m3u8())
# 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):
if sys.version_info[0] >= 3:
@@ -1387,7 +1460,7 @@ class Movistar(object):
url = 'https://grmovistar.imagenio.telefonica.net/asfe/rest/tvMediaURLs?tvProgram.id='+ program_id +'&svc=startover'
session_request = '{"contentID":' + program_id +',"streamType":"CUTV"}'
args = urlencode({'action': 'play', 'stype': 'u7d', 'id': program_id, 'url': url, 'session_request': session_request})
t['stream'] = 'plugin://plugin.video.movistarplus/?' + args
t['stream'] = 'plugin://plugin.video.kodimovplus/?' + args
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']}
@@ -1456,7 +1529,7 @@ class Movistar(object):
if url:
url = url.replace('&', '&amp;')
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' +
' <title>{}</title>\n'.format(html_escape(e['title'])) +
' <sub-title>{}</sub-title>\n'.format(html_escape(e['subtitle'])))
@@ -1473,9 +1546,10 @@ class Movistar(object):
res.append(' <actor>{}</actor>\n'.format(c['name']))
res.append(' </credits>\n');
res.append('</programme>\n')
if self.add_extra_channels:
res.extend(kodimov.extra_channels_xmltv(self.net.session))
res.append('</tv>\n')
with io.open(filename, 'w', encoding='utf-8', newline='') as handle:
handle.write(''.join(res))
kodimov.write_if_changed(filename, ''.join(res))
def save_credentials(self, username, password):
from .b64 import encode_base64
@@ -1545,6 +1619,12 @@ class Movistar(object):
os.makedirs(self.config_dir + 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
def load_file_if_exists(filename):
content = None
@@ -1560,3 +1640,21 @@ class Movistar(object):
content = unicode(content, 'utf-8')
with io.open(filename, 'w', encoding='utf-8') as handle:
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', [])
+3 -2
View File
@@ -11,9 +11,10 @@ from requests.adapters import HTTPAdapter
from requests.packages.urllib3.poolmanager import PoolManager
class CustomAdapter(HTTPAdapter, object):
if sys.version_info[0] == 3:
def init_poolmanager(self, connections, maxsize, block=False):
ctx = ssl.create_default_context()
ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT
ctx.set_ciphers('DEFAULT:!BRAINPOOL')
self.poolmanager = PoolManager(
num_pools=connections,
maxsize=maxsize,
@@ -24,5 +25,5 @@ class CustomAdapter(HTTPAdapter, object):
class MySession(requests.Session, object):
def __init__(self, *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())
+99 -41
View File
@@ -8,6 +8,8 @@ import sys
import json
import io
import os.path
from datetime import datetime, timedelta
from .timeconv import isodate2date
import xbmc
import xbmcgui
@@ -96,17 +98,16 @@ def play(params):
# url = url.replace('DASH_WPC_WIDEVINE', 'DASH_TV_WIDEVINE')
# U7D from start and end times
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']))
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}'
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))
#if stype == 'tv' and all(param in params for param in ['cas_id', 'start_time', 'end_time']):
# start = datetime.utcfromtimestamp(int(params['start_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}'
# 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))
# 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))
if addon.getSettingBool('manifest_modification') and proxy:
@@ -122,11 +123,18 @@ def play(params):
#headers += '&Host=wv-ottlic-f3.imagenio.telefonica.net'
manifest_headers = 'User-Agent=' + useragent
if stype in ['tv', 'u7d', 'rec']:
cdn_token = m.cache.load('cdn.conf')
if True: #stype in ['tv', 'u7d', 'rec']:
cdn_token = m.cache.load('cdn.conf', 60)
if not cdn_token:
cdn_token = m.get_cdntoken()
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))
manifest_headers += '&x-tcdn-token=' + cdn_token
@@ -147,20 +155,27 @@ def play(params):
'8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0')
play_item = xbmcgui.ListItem(path= url)
play_item.setProperty('inputstream.adaptive.manifest_type', 'mpd')
#play_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
license_type = 'com.widevine.alpha'
license_key = ''
if format(addon.getSetting('drm_type')) == 'Playready':
play_item.setProperty('inputstream.adaptive.license_type', 'com.microsoft.playready')
else:
play_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha')
license_type = 'com.microsoft.playready'
if addon.getSettingBool('use_proxy_for_license') and proxy:
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)
LOG('license_url: {}'.format(license_url))
play_item.setProperty('inputstream.adaptive.license_key', license_url)
license_key = '{}/license?token={}&stype={}&session_request={}&request_id={}'.format(proxy, quote_plus(token), stype, quote_plus(params['session_request']), request_id)
LOG('license_key: {}'.format(license_key))
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')
play_item.setProperty('inputstream.adaptive.stream_headers', manifest_headers)
play_item.setProperty('inputstream.adaptive.manifest_headers', manifest_headers)
@@ -206,7 +221,9 @@ def play(params):
for sub in sublist:
filename = os.path.join(subfolder, sub['lang'])
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
with io.open(filename + '.ttml', 'w', encoding='utf-8', newline='') as handle:
handle.write(content.decode('utf-8'))
@@ -230,10 +247,12 @@ def play(params):
xbmcplugin.setResolvedUrl(_handle, True, listitem=play_item)
LOG('**** session_opened: {}'.format(session_opened))
is_sport_channel = (stype == 'tv' and channel_id in ['MLIGA', 'DAZNLI', 'MLIG1', 'CHAPIO', 'CHAP1', 'CHAP2', 'MLIGUH', 'CHAUHD', 'CHUHD1'])
if session_opened or is_sport_channel:
if is_sport_channel:
last_time = 0 #time.time()
watermarks = []
if stype == 'tv' and 'uid' in params:
watermarks = m.get_watermarks(params['uid'])
LOG('watermarks: {}'.format(watermarks))
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
@@ -242,10 +261,15 @@ def play(params):
while not monitor.abortRequested() and player.running:
monitor.waitForAbort(10)
#LOG('**** waiting')
if is_sport_channel and player.isPlaying():
now = time.time()
if now - last_time >= 15*60:
last_time = now
if player.isPlaying() and watermarks:
now = isodate2date(datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'))
for wm in list(watermarks):
start = isodate2date(wm['start'])
stop = isodate2date(wm['stop'])
if now >= start:
watermarks.remove(wm)
if now < stop:
duration = (stop - start).total_seconds()
#show_notification(m.account['id'], xbmcgui.NOTIFICATION_INFO)
w = window.getWidth()
h = window.getHeight()
@@ -254,7 +278,7 @@ def play(params):
pos_y = h-200
label.setPosition(pos_x, pos_y)
window.addControl(label)
time.sleep(20)
time.sleep(duration)
window.removeControl(label)
if session_opened:
d = m.delete_session()
@@ -374,6 +398,7 @@ def add_videos(category, ctype, videos, ref=None, url_next=None, url_prev=None,
url = get_url(action='play', id=t['id'], url=t['url'], session_request=t['session_request'], stype=t['stream_type'])
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)
elif t['type'] == 'series':
list_item = xbmcgui.ListItem(label = title_name)
@@ -471,7 +496,6 @@ def list_profiles(params):
def list_epg(params):
LOG('list_epg: {}'.format(params))
from datetime import datetime, timedelta
from .timeconv import my_strftime
if 'id' in params:
if not 'date' in params:
@@ -638,6 +662,20 @@ def login():
else:
show_notification(addon.getLocalizedString(30166)) # Failed
def network_login():
account_number = addon.getSetting('account_number')
if not account_number and not m.cache.load_file('account.json') and not m.cache.load_file('tokens.json'):
account_number = input_window(addon.getLocalizedString(30502)) # Account number
if not account_number: return
addon.setSetting('account_number', account_number)
# Keeps device_id.conf: clearing the session would register a new device.
if m.try_network_login(account_number, force=True):
show_notification(addon.getLocalizedString(30503), xbmcgui.NOTIFICATION_INFO) # Logged in
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():
filename = xbmcgui.Dialog().browseSingle(1, addon.getLocalizedString(30182), '', '.key')
if filename:
@@ -662,6 +700,7 @@ def export_credentials():
def select_account(id, name):
m.switch_account(id)
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
if addon.getSettingBool('enable_key_login'):
add_menu_option(addon.getLocalizedString(30181), get_url(action='login_with_key')) # Login with key
@@ -716,7 +755,7 @@ def create_iptv_settings():
show_notification(addon.getLocalizedString(30319), xbmcgui.NOTIFICATION_INFO)
output_file = 'instance-settings-91.xml' if kodi_version > 19 else 'settings.xml'
epg_url = None
if addon.getSettingBool('use_external_epg'):
if False: #addon.getSettingBool('use_external_epg'):
epg_url = addon.getSetting('epg_url')
try:
pvr_addon = xbmcaddon.Addon('pvr.iptvsimple')
@@ -726,28 +765,37 @@ def create_iptv_settings():
if os.path.exists(filename):
res = xbmcgui.Dialog().yesno(addon.getLocalizedString(30322), addon.getLocalizedString(30323))
if res == False: return
save_iptv_settings(filename, 'Movistarplus', 'movistarplus', epg_url)
save_iptv_settings(filename, 'KodiMov+', 'kodimovplus', epg_url)
export_epg_now()
except:
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):
# 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):
show_notification(addon.getLocalizedString(30314).format(message), xbmcgui.NOTIFICATION_INFO)
notify(addon.getLocalizedString(30314).format(message))
if not m.logged: return
only_subscribed = addon.getSettingBool('only_subscribed')
channels_filename = os.path.join(profile_dir, 'channels.m3u8')
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)
if not addon.getSettingBool('use_external_epg'):
show_notification(addon.getLocalizedString(30311), xbmcgui.NOTIFICATION_INFO)
if True: #not addon.getSettingBool('use_external_epg'):
notify(addon.getLocalizedString(30311))
m.export_epg_to_xml(epg_filename, addon.getSettingInt('export_days'), report_progress, only_subscribed)
show_notification(addon.getLocalizedString(30313), xbmcgui.NOTIFICATION_INFO)
notify(addon.getLocalizedString(30313))
def to_wishlist(params):
@@ -792,6 +840,10 @@ def router(paramstring):
login_with_key()
elif params['action'] == 'login':
login()
elif params['action'] == 'network_login':
network_login()
elif params['action'] == 'export_channels_now':
export_channels_now()
elif params['action'] == 'export_key':
export_key()
elif params['action'] == 'export_credentials':
@@ -837,7 +889,7 @@ def router(paramstring):
elif params['action'] == 'create_iptv_settings':
create_iptv_settings()
elif params['action'] == 'export_epg_now':
export_epg_now()
export_epg_now(quiet=params.get('quiet') == '1')
elif params['action'] == 'to_wishlist':
to_wishlist(params)
elif 'iptv' in params['action']:
@@ -856,8 +908,10 @@ def router(paramstring):
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(30108), get_url(action='devices'), icon='devices.png') # Devices
elif m.expired_access_token:
else:
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(30450), get_url(action='show_donation_dialog'), icon='qr.png') # Donation
@@ -878,8 +932,12 @@ def run():
reuse_devices = addon.getSettingBool('reuse_devices')
LOG('profile_dir: {}'.format(profile_dir))
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.hide_unplayable = addon.getSettingBool('hide_unplayable')
m.add_extra_channels = addon.getSettingBool('extra_channels')
if addon.getSettingBool('uhd'):
m.quality = 'UHD'
+25 -4
View File
@@ -38,6 +38,7 @@ import json
import requests
import threading
import socket
import time
from contextlib import closing
from .b64 import encode_base64
@@ -55,6 +56,10 @@ previous_tokens = []
reregister_needed = False
# (connect_timeout, read_timeout) in seconds for requests to upstream (CDN) servers.
MANIFEST_TIMEOUT = (2, 3)
def is_ascii(s):
try:
return s.isascii()
@@ -73,7 +78,9 @@ def mvs():
if not mvs_o:
from .movistar import Movistar
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
@@ -94,7 +101,10 @@ class RequestHandler(BaseHTTPRequestHandler):
if 'x-tcdn-token' in self.headers:
additional_headers['x-tcdn-token'] = self.headers['x-tcdn-token']
#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))
baseurl = os.path.dirname(response.url)
LOG('baseurl: {}'.format(baseurl))
@@ -129,6 +139,8 @@ class RequestHandler(BaseHTTPRequestHandler):
#LOG('content: {}'.format(content))
manifest_data = content
t2 = time.time()
LOG('manifest processing took {:.2f}s'.format(t2 - t1))
self.send_response(200)
#self.send_header('Content-type', 'application/xml')
self.send_header('Content-type', 'text/plain')
@@ -138,7 +150,7 @@ class RequestHandler(BaseHTTPRequestHandler):
pos = path.find('=')
url = path[pos+1:]
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
ttml.parse_ttml_from_string(content)
sub_data = ttml.generate_vtt()
@@ -156,6 +168,10 @@ class RequestHandler(BaseHTTPRequestHandler):
else:
self.send_response(404)
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:
self.send_response(500)
self.end_headers()
@@ -238,6 +254,10 @@ class RequestHandler(BaseHTTPRequestHandler):
self.send_response(response.status_code)
self.end_headers()
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:
self.send_response(500)
self.end_headers()
@@ -245,7 +265,7 @@ class RequestHandler(BaseHTTPRequestHandler):
HOST = '127.0.0.1'
PORT = 57011
PORT = 57031
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
@@ -276,6 +296,7 @@ class Proxy(object):
self._httpd_thread = threading.Thread(target=self._server.serve_forever)
self._httpd_thread.start()
self.proxy_address = 'http://{}:{}'.format(HOST, port)
addon.setSetting('proxy_address', self.proxy_address)
self.started = True
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
import random
useragents = [
"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",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.3",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.3",
"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.",
"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"
FIREFOX_VERSIONS = ['140.0', '141.0', '142.0']
FIREFOX_PLATFORMS = [
'X11; Linux x86_64; rv:{v}',
'Windows NT 10.0; Win64; x64; rv:{v}',
'Macintosh; Intel Mac OS X 10.15; rv:{v}',
]
#useragent = random.choice(useragents)
useragent = 'Dalvik/2.1.0 (Linux; U; Android 12; 22126RN91Y Build/SP1A.210812.016)'
def firefox_ua():
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()
+15 -6
View File
@@ -1,5 +1,10 @@
<settings>
<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 label="30017" type="bool" id="only_subscribed" default="true"/>
<setting label="30020" type="bool" id="channels_with_epg" default="false"/>
@@ -11,8 +16,9 @@
<setting label="30019" type="action" id="is_settings" action="Addon.OpenSettings(inputstream.adaptive)" enable="System.HasAddon(inputstream.adaptive)" option="close"/>
<!--
<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 label="30002">
<setting label="30011" type="bool" id="manifest_modification" default="true"/>
@@ -33,8 +39,8 @@
<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="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.epg_uri" default="plugin://plugin.video.movistarplus/iptv/epg" 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.kodimovplus/iptv/epg" visible="false"/>
</category>
-->
@@ -46,17 +52,20 @@
</category>
<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 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 type="sep"/>
-->
<setting label="30305" type="bool" id="export_epg" default="false"/>
<setting label="30508" type="bool" id="export_channels" default="true"/>
<setting label="30305" type="bool" id="export_epg" default="true"/>
<setting label="30315" type="number" id="export_days" default="2" enable="eq(-1,true)"/>
<setting type="sep"/>
<setting label="30312" type="action" id="export_epg_now" action="RunPlugin(plugin://plugin.video.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 label="30308" type="action" action="Addon.OpenSettings(pvr.iptvsimple)" option="close" visible="System.HasAddon(pvr.iptvsimple)"/>
@@ -43,7 +43,7 @@
<width>1030</width>
<height>70</height>
<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>
<aligny>center</aligny>
<shadowcolor>FF000000</shadowcolor>
+11 -10
View File
@@ -4,12 +4,12 @@
from __future__ import unicode_literals
import os
import time
import xbmc
import xbmcgui
from resources.lib.proxy import Proxy
from resources.lib.addon import *
from resources.lib.log import LOG
from resources.lib.cache import Cache
def export_epg():
from datetime import datetime, timedelta
@@ -30,25 +30,26 @@ def export_epg():
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)')
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now)')
if __name__ == '__main__':
LOG('Service started')
proxy = Proxy()
proxy.start()
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. Opening the add-on also renews the 10-day Movistar login.
next_export = time.time() + 60
monitor = xbmc.Monitor()
while not monitor.abortRequested():
if monitor.waitForAbort(1):
break
if time.time() >= next_export:
next_export = time.time() + 6 * 3600
if addon.getSettingBool('export_epg'): # channels + programme guide
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_epg_now&quiet=1)')
elif addon.getSettingBool('export_channels'):
xbmc.executebuiltin('RunPlugin(plugin://plugin.video.kodimovplus/?action=export_channels_now)')
proxy.stop()