Files
kodimovplus/resources/lib/proxy.py
T
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

318 lines
12 KiB
Python

#!/usr/bin/env python
# encoding: utf-8
#
# SPDX-License-Identifier: LGPL-2.1-or-later
from __future__ import unicode_literals, absolute_import, division
try: # Python 3
from http.server import BaseHTTPRequestHandler, HTTPServer
except ImportError: # Python 2
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
try: # Python 3
from socketserver import TCPServer, ThreadingMixIn
except ImportError: # Python 2
from SocketServer import TCPServer, ThreadingMixIn
try: # Python 3
from urllib.parse import unquote, quote_plus
except ImportError: # Python 2
from urllib import unquote, quote_plus
try: # Python 3
from urllib.parse import parse_qsl
except: # Python 2
from urlparse import parse_qsl
"""
try: # Python 3
from SocketServer import ThreadingTCPServer
except ImportError: # Python 2
from socketserver import ThreadingTCPServer
"""
import os
import re
import json
import requests
import threading
import socket
import time
from contextlib import closing
from .b64 import encode_base64
from .log import LOG
from .mysession import MySession
from .addon import profile_dir, addon
from .useragent import useragent
from ttml2ssa import Ttml2SsaAddon
ttml = Ttml2SsaAddon()
session = MySession()
session.headers.update({'user-agent': useragent})
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()
except:
return all(ord(c) < 128 for c in s)
def try_load_json(text):
try:
return json.loads(text)
except:
return None
mvs_o = None
def mvs():
global mvs_o
if not mvs_o:
from .movistar import Movistar
reuse_devices = addon.getSettingBool('reuse_devices')
mvs_o = Movistar(profile_dir, reuse_devices,
network_login=addon.getSettingBool('network_login'),
account_number=addon.getSetting('account_number'))
return mvs_o
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
"""Handle http get requests, used for manifest"""
path = self.path # Path with parameters received from request e.g. "/manifest?id=234324"
print('HTTP GET Request received to {}'.format(path))
try:
#if True:
if 'manifest' in path:
pos = path.find('=')
url = path[pos+1:]
LOG('url: {}'.format(url))
#LOG('request headers: {}'.format(self.headers))
additional_headers = {}
if 'x-tcdn-token' in self.headers:
additional_headers['x-tcdn-token'] = self.headers['x-tcdn-token']
#LOG('additional_headers: {}'.format(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))
content = response.content.decode('utf-8')
if addon.getSettingBool('proxy_process_subs'):
LOG(self.headers.get('Host'))
my_address = 'http://' + self.headers.get('Host')
LOG('my_address: {}'.format(my_address))
content = content.replace('mimeType="application/ttml+xml"', 'mimeType="text/vtt"')
content = re.sub(r'<BaseURL>(.*?)\.ttml<\/BaseURL>', r'<BaseURL>{}/?subtitle={}/\1.ttml</BaseURL>'.format(my_address, baseurl), content)
pos = content.find('<Period id')
if pos > -1:
content = content[:pos] + '<BaseURL>' + baseurl + '/</BaseURL>' + content[pos:]
if addon.getSettingBool('fix_languages'):
replacements = {'qaa': 'eng', 'srd': 'es-[CC]', 'ads': 'es-[ADS]'}
for key, value in replacements.items():
content = content.replace('lang="{}"'.format(key), 'lang="{}"'.format(value))
content = re.sub(r'lang="q([^"]*)"', r'lang="es-[q\1]"', content)
if addon.getSettingBool('delete_ec3_audio'):
pattern = re.compile(r'<AdaptationSet[^>]*contentType="audio"[^>]*>.*?</AdaptationSet>', re.DOTALL)
matches = pattern.findall(content)
for match in matches:
if 'codecs="ec-3"' in match:
content = content.replace(match, "<!-- Deleted ec-3 audio track -->\n")
# For U7D
content = content.replace('timeShiftBufferDepth="PT3600S"', 'timeShiftBufferDepth="PT10800S"')
#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')
self.end_headers()
self.wfile.write(manifest_data.encode('utf-8'))
elif 'subtitle' in path:
pos = path.find('=')
url = path[pos+1:]
LOG('subtitle url: {}'.format(url))
response = session.get(url, allow_redirects=True, timeout=MANIFEST_TIMEOUT)
content = response.content
ttml.parse_ttml_from_string(content)
sub_data = ttml.generate_vtt()
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(sub_data.encode('utf-8'))
elif 'fragment' in path:
pos = path.find('=')
url = path[pos+1:]
LOG('url: {}'.format(url))
self.send_response(301)
self.send_header('Location', url)
self.end_headers()
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()
LOG('Exception error: {}'.format(str(e)))
def do_POST(self):
"""Handle http post requests, used for license"""
path = self.path # Path with parameters received from request e.g. "/license?id=234324"
print('HTTP POST Request received to {}'.format(path))
if '/license' not in path:
self.send_response(404)
self.end_headers()
return
try:
#if True:
pos = path.find('?')
path = path[pos+1:]
params = dict(parse_qsl(path))
LOG('params: {}'.format(params))
length = int(self.headers.get('content-length', 0))
isa_data = self.rfile.read(length)
LOG('isa_data length: {}'.format(length))
LOG('isa_data: {}'.format(encode_base64(isa_data)))
token = params['token']
LOG('token: {}'.format(token))
global previous_tokens, reregister_needed
while True:
LOG('reregister_needed: {}'.format(reregister_needed))
# Discard previous mvs_o, it may have expired tokens
global mvs_o
mvs_o = None
if reregister_needed and addon.getSettingBool('reregister'):
mvs().unregister_device()
mvs().register_device()
if token in previous_tokens and params['stype'] == 'vod':
LOG('duplicated token')
LOG('previous_tokens: {}'.format(previous_tokens))
d = mvs().open_session(params['session_request'])
LOG('Open session (proxy): d: {}'.format(d))
if 'resultData' in d and 'cToken' in d['resultData']:
token = d['resultData']['cToken']
LOG('new token: {}'.format(token))
session_response = mvs().delete_session()
LOG('Delete session (proxy): {}'.format(session_response))
previous_tokens.append(token)
headers = {
'User-Agent': useragent,
'Accept': '*/*',
'Accept-Language': 'es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3',
'Referer': 'https://ver.movistarplus.es/',
'nv-authorizations': quote_plus(token),
'Origin': 'https://ver.movistarplus.es',
}
LOG('headers: {}'.format(headers))
url = 'https://wv-ottlic-f3.imagenio.telefonica.net/TFAESP/wvls/contentlicenseservice/v1/licenses'
response = session.post(url, data=isa_data, headers=headers)
license_data = response.content
LOG('license response length: {}'.format(len(license_data)))
if is_ascii(license_data):
LOG('license response: {}'.format(license_data))
d = try_load_json(license_data)
if d and 'errorCode' in d:
# KodiMov+: the error is shown only when the retry below
# doesn't get a licence. The first request for a LALIGA /
# Liga de Campeones channel is refused with 4027 every time,
# and passes once the device is registered again (a pause
# and a plain retry don't help; tested 2026-11-13).
if d['errorCode'] == 4027:
if not reregister_needed and addon.getSettingBool('reregister'):
reregister_needed = True
continue
from .gui import show_notification
show_notification('Error {}: {}'.format(d['errorCode'], d['message']))
else:
LOG('license response: {}'.format(encode_base64(license_data)))
break
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()
LOG('Exception error: {}'.format(str(e)))
HOST = '127.0.0.1'
PORT = 57031
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class Proxy(object):
started = False
def check_port(self, port=0, default=False):
try:
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind((HOST, port))
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return s.getsockname()[1]
except:
return default
def start(self):
if self.started:
return
port = self.check_port(PORT)
if not port:
port = self.check_port(0)
LOG('port: {}'.format(port))
self._server = ThreadedHTTPServer((HOST, port), RequestHandler)
self._server.allow_reuse_address = True
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))
def stop(self):
if not self.started:
return
self._server.shutdown()
self._server.server_close()
self._server.socket.close()
self._httpd_thread.join()
self.started = False
LOG("Proxy: Stopped")