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>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-11-07 10:32:59 +01:00
1 parent ccc17b4e5c
commit 066a203e9e
35 files changed
+412 -30

No files matched your search

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.
+170
View File
@@ -0,0 +1,170 @@
# 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',
'logo': 'https://i.imgur.com/yFy6gxg.png',
'url': 'https://crtvg-tvg2.flumotion.cloud/playlist.m3u8'},
{'id': 'TeleVigo', 'name': 'TeleVigo', 'chno': 155, 'group': 'Galicia',
'logo': 'https://i.imgur.com/4iVmlFa.png',
'url': 'https://cloud.fastchannel.es/mic/manifiest/hls/televigo/televigo.m3u8'},
]
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():
items = []
for c in EXTRA_CHANNELS:
items.append('#EXTINF:-1 tvg-name="{name}" tvg-id="{id}" tvg-logo="{logo}" tvg-chno="{chno}" group-title="{group}",{name}\n{url}\n\n'.format(**c))
return ''.join(items)
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
+36 -6
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'],
@@ -666,6 +692,7 @@ class Movistar(object):
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':
@@ -684,6 +711,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):
@@ -1375,7 +1404,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
@@ -1390,9 +1419,10 @@ class Movistar(object):
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(item)
res = '#EXTM3U\n## Movistar+\n{}'.format(''.join(items))
with io.open(filename, 'w', encoding='utf-8', newline='') as handle:
handle.write(res)
if self.add_extra_channels:
items.append(kodimov.extra_channels_m3u8())
res = '#EXTM3U\n## KodiMov+\n{}'.format(''.join(items))
return kodimov.write_if_changed(filename, res)
def export_epg(self, date=None, duration=2):
if sys.version_info[0] >= 3:
@@ -1419,7 +1449,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']}
+35 -4
View File
@@ -662,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:
@@ -686,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
@@ -750,11 +765,17 @@ 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_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():
def report_progress(message):
show_notification(addon.getLocalizedString(30314).format(message), xbmcgui.NOTIFICATION_INFO)
@@ -816,6 +837,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':
@@ -880,8 +905,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:
show_notification(addon.getLocalizedString(30208))
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
@@ -902,8 +929,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'
+4 -2
View File
@@ -78,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
@@ -263,7 +265,7 @@ class RequestHandler(BaseHTTPRequestHandler):
HOST = '127.0.0.1'
PORT = 57011
PORT = 57031
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
+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)