commit 103a4899852ab7f87069c22f429c22ba6c43e799 Author: Matthias Berner Date: Mon Jul 6 14:27:51 2026 +0200 Initial commit: xStream Downloader diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c0541c --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +*.egg-info/ +dist/ +build/ +.coverage +htmlcov/ +.env +venv/ +.venv/ +*.swp +*.swo +.DS_Store diff --git a/app.py b/app.py new file mode 100644 index 0000000..d39cc9e --- /dev/null +++ b/app.py @@ -0,0 +1,371 @@ +import sys, os, time, json, logging, hashlib, threading, types + +BASE = os.path.dirname(os.path.abspath(__file__)) + +# ─── Step 1: Set up mocks BEFORE importing anything from xstream ─────────────── + +# Descriptor that acts as both a base-class AND a factory. +# "class X(xbmc.Player)" needs a class. "xbmc.Player()" needs a callable. +class _PlayerFactory: + def __init__(self, cls, *args, **kwargs): + self._cls = cls + def __call__(self, *a, **k): + return self._cls(*a, **k) + def __get__(self, obj, objtype=None): + return self._cls + +class MockPlayer(object): + def __init__(self, *args, **kwargs): + pass + def play(self, *a, **kw): pass + def isPlayingVideo(self): return False + def getPlayingFile(self): return None + def stop(self): pass + def pause(self): pass + def getTotalTime(self): return 0 + def getTime(self): return 0, 0 + +class MockMonitor: + def __init__(self): pass + def abortRequested(self): return False + def waitForAbort(self, *a): time.sleep(a[0] if a else 1) + +class MockPlayList: + def __init__(self): pass + def clear(self): pass + def add(self, *a): pass + +class MockXbmcguiWindow: + def __init__(self): pass + def getProperty(self, k): return '' + def setProperty(self, k, v): pass + def clearProperty(self, k): pass + def clearProperties(self): pass + +class MockXbmcgui: + Window = type('Window', (), {'10000': MockXbmcguiWindow()})() + NOTIFICATION_INFO = 'info' + NOTIFICATION_WARNING = 'warning' + NOTIFICATION_ERROR = 'error' + @staticmethod + def notification(*a, **k): pass + @staticmethod + def ok(*a, **k): return 1 + @staticmethod + def select(*a, **k): return 0 + @staticmethod + def textviewer(*a, **k): pass + Dialog = type('Dialog', (), { + 'ok': staticmethod(lambda *a, **k: True), + 'yesno': staticmethod(lambda *a, **k: True), + 'select': staticmethod(lambda *a, **k: 0), + 'browse': staticmethod(lambda *a, **k: ''), + 'notification': staticmethod(lambda *a, **k: None), + 'multiselect': staticmethod(lambda *a, **k: []), + })() + ListItem = type('ListItem', (), {'path': ''}) + WindowDialog = type('WindowDialog', (), {'__init__': lambda s: None}) + WindowXMLDialog = type('WindowXMLDialog', (), { + '__init__': lambda s, *a, **k: None, + 'getProperty': lambda s, k: '', + 'setProperty': lambda s, k, v: None, + }) + ControlButton = type('ControlButton', (), {'__init__': lambda s, *a, **k: None}) + ControlImage = type('ControlImage', (), {'__init__': lambda s, *a, **k: None}) + ControlFadeLabel = type('ControlFadeLabel', (), {'__init__': lambda s, *a, **k: None}) + ACTION_MOVE_DOWN = 4 + ACTION_MOVE_UP = 3 + ACTION_MOVE_LEFT = 1 + ACTION_MOVE_RIGHT = 2 + ACTION_NAV_BACK = 92 + ACTION_SELECT_ITEM = 7 + +class MockXbmcplugin: + @staticmethod + def setResolvedUrl(*a): pass + +class MockXbmcvfs: + @staticmethod + def translatePath(p): return p.replace('special://home', BASE) + @staticmethod + def mkdirs(p): os.makedirs(p, exist_ok=True) + @staticmethod + def exists(p): return os.path.exists(p) + @staticmethod + def delete(p): os.path.exists(p) and os.remove(p) + @staticmethod + def writeFile(p, d): open(p, 'w').write(d) + @staticmethod + def readFile(p): return open(p).read() if os.path.exists(p) else '' + +class MockXbmcaddonAddon: + _settings = {} + def __init__(self, addon_id='plugin.video.xstream', *a, **k): + self._addon_id = addon_id + self._settings = {} + self._info = { + 'plugin.video.xstream': {'name': 'xStream', 'id': 'plugin.video.xstream', 'version': '3.8.0', 'path': BASE, 'profile': os.path.join(BASE, '.profile')}, + 'script.module.resolveurl': {'name': 'ResolveURL', 'id': 'script.module.resolveurl', 'version': '5.1.190', 'path': BASE, 'profile': os.path.join(BASE, '.profile')}, + 'xbmc.addon': {'name': 'Kodi', 'id': 'xbmc.addon', 'version': '20.3', 'path': BASE, 'profile': os.path.join(BASE, '.profile')}, + } + def getAddonInfo(self, k): + return self._info.get(self._addon_id, {'name': 'xStream', 'id': self._addon_id, 'version': '1.0.0', 'path': BASE, 'profile': os.path.join(BASE, '.profile')}).get(k, '') + def getSetting(self, k): return self._settings.get(k, '') + def setSetting(self, k, v): self._settings[k] = v + def getLocalizedString(self, k): return str(k) + def openSettings(self): pass + +class MockXbmcaddon: + Addon = MockXbmcaddonAddon + +# Create xbmc as a proper module-type object so "class X(xbmc.Player)" works +_xbmc_mod = types.ModuleType('xbmc') +_xbmc_mod.LOGDEBUG = 0 +_xbmc_mod.LOGINFO = 1 +_xbmc_mod.LOGWARNING = 2 +_xbmc_mod.LOGERROR = 3 +_xbmc_mod.LOGFATAL = 4 +_xbmc_mod.getCondVisibility = lambda *a: False +_xbmc_mod.getInfoLabel = lambda *a: '' +_xbmc_mod.translatePath = lambda *a: os.path.join(BASE, '.xbmc') +_xbmc_mod.Player = _PlayerFactory(MockPlayer) # works as class AND as callable +_xbmc_mod.Monitor = MockMonitor +_xbmc_mod.PlayList = MockPlayList +_xbmc_mod.sleep = lambda ms: time.sleep(ms/1000) +_xbmc_mod.executebuiltin = lambda *a: None +_xbmc_mod.executeJSONRPC = lambda *a: '{}' +_xbmc_mod.log = lambda msg, level=None: None +_xbmc_mod.Keyboard = lambda *a, **k: type('Keyboard', (), {'doModal': lambda s: None, 'isConfirmed': lambda s: False, 'getText': lambda s: ''})() +_xbmc_mod.getSupportedMedia = lambda media: '|.mkv|.mp4|.avi|.mov|.wmv|.ts|.m4v|.flv|.webm|' +sys.modules['xbmc'] = _xbmc_mod + +sys.modules['xbmcgui'] = MockXbmcgui +sys.modules['xbmcplugin'] = MockXbmcplugin +sys.modules['xbmcvfs'] = MockXbmcvfs +sys.modules['xbmcaddon'] = MockXbmcaddon + +# kodi_six also imports xbmc, xbmcgui, xbmcplugin, xbmcaddon, xbmcvfs +# Provide them via a proxy module +import importlib.util +_kodi_six_lib = os.path.join(BASE, 'deps', 'kodi_six', 'libs') +sys.path.insert(0, _kodi_six_lib) +_spec = importlib.util.spec_from_file_location('kodi_six', os.path.join(_kodi_six_lib, 'kodi_six', '__init__.py')) +_real_ks = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_real_ks) +_kodi_six_mod = types.ModuleType('kodi_six') +_kodi_six_mod.PY2 = _real_ks.PY2 +_kodi_six_mod.py2_encode = _real_ks.py2_encode +_kodi_six_mod.py2_decode = _real_ks.py2_decode +_kodi_six_mod.encode_decode = _real_ks.encode_decode +_kodi_six_mod.xbmc = _xbmc_mod +_kodi_six_mod.xbmcgui = MockXbmcgui +_kodi_six_mod.xbmcplugin = MockXbmcplugin +_kodi_six_mod.xbmcaddon = MockXbmcaddon +_kodi_six_mod.xbmcvfs = MockXbmcvfs +sys.modules['kodi_six'] = _kodi_six_mod + +# ─── Step 2: Add dependency paths ────────────────────────────────────────────── +# Each script.module.X has its python package in lib/X/ +_deps_map = { + 'six': ('six', 'lib/six.py'), + 'kodi_six': ('kodi_six', 'libs/kodi_six/__init__.py'), + 'requests': ('requests', 'lib/requests/__init__.py'), + 'resolveurl': ('resolveurl', 'lib/resolveurl/__init__.py'), + 'pyaes': ('pyaes', 'lib/pyaes/__init__.py'), +} +for dep, (pkg_name, pkg_path) in _deps_map.items(): + p = os.path.join(BASE, 'deps', dep) + if os.path.exists(p): + sys.path.insert(0, p) + lib_p = os.path.join(p, 'lib') + if os.path.exists(lib_p): + sys.path.insert(0, lib_p) + +# ─── Step 3: Add xstream plugin path ────────────────────────────────────────── +sys.path.insert(0, os.path.join(BASE, 'xstream')) + +# ─── Step 4: Now import xstream modules ──────────────────────────────────────── +from resources.lib.config import cConfig +from resources.lib.logger import logger +from resources.lib.cache import cCache +from resources.lib.handler.ParameterHandler import ParameterHandler +from resources.lib.handler.requestHandler import cRequestHandler +from resources.lib.handler.pluginHandler import cPluginHandler +from resources.lib.tools import cParser, cUtil +from resources.lib.gui.guiElement import cGuiElement +from resources.lib.gui.gui import cGui +from resources.lib.gui.hoster import cHosterGui + +# ─── Step 5: Flask app ──────────────────────────────────────────────────────── +from flask import Flask, jsonify, request, send_file +from waitress import serve + +app = Flask(__name__) +app.logger.setLevel(logging.WARNING) + +# ─── Download Manager ───────────────────────────────────────────────────────── +class DownloadManager: + def __init__(self): + self.downloads = {} + self.lock = threading.Lock() + self.base_dir = os.path.join(BASE, 'downloads') + os.makedirs(self.base_dir, exist_ok=True) + + def add(self, url, filename, resolved_url): + import requests as _req + with self.lock: + did = hashlib.md5(url.encode()).hexdigest()[:8] + self.downloads[did] = { + 'id': did, 'title': filename, 'source_url': url, 'url': resolved_url, + 'status': 'downloading', 'progress': 0, 'path': None, 'error': None, + 'started': time.time(), 'finished': None, + } + t = threading.Thread(target=self._download, args=(did, resolved_url, filename), daemon=True) + t.start() + return did + + def _download(self, did, url, filename): + import requests as _req + safe_name = "".join(c for c in filename if c not in '/\\:*?"<>|').strip() or 'video' + filepath = os.path.join(self.base_dir, safe_name) + try: + resp = _req.get(url, stream=True, timeout=60, + headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}) + resp.raise_for_status() + total = int(resp.headers.get('content-length', 0)) + downloaded = 0 + with open(filepath, 'wb') as f: + for chunk in resp.iter_content(chunk_size=65536): + if chunk: + f.write(chunk) + downloaded += len(chunk) + pct = int(100 * downloaded / total) if total else 0 + with self.lock: + self.downloads[did]['progress'] = pct + with self.lock: + self.downloads[did].update(status='complete', progress=100, path=filepath, finished=time.time()) + except Exception as e: + with self.lock: + self.downloads[did].update(status='error', error=str(e), finished=time.time()) + + def list(self): + with self.lock: + return list(self.downloads.values()) + + def get(self, did): + with self.lock: + return self.downloads.get(did) + +dm = DownloadManager() + +# ─── API Routes ──────────────────────────────────────────────────────────────── + +@app.route('/api/sites') +def api_sites(): + try: + ph = cPluginHandler() + plugins = ph.getAvailablePlugins() + return jsonify(plugins) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/sites//entries') +def api_entries(site_id): + url = request.args.get('url', '') + search = request.args.get('search', '') + try: + mod = __import__(site_id, globals(), locals()) + if search: + URL_SEARCH = getattr(mod, 'URL_SEARCH', None) + if URL_SEARCH: + url = URL_SEARCH % cParser.quotePlus(search) + func = getattr(mod, '_search', None) or getattr(mod, 'showSearch', None) + elif url: + func = getattr(mod, 'showEntries', None) + else: + func = getattr(mod, 'load', None) + if func: + result = func(url) + return jsonify({'entries': _entries_to_dict(result)}) + return jsonify({'entries': []}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def _entries_to_dict(result): + """Convert site function results to a list of entry dicts.""" + if not result: + return [] + entries = [] + for item in result: + if isinstance(item, cGuiElement): + entries.append({ + 'title': item.getTitle(), + 'thumb': item.getThumbnail(), + 'url': getattr(item, '_cGuiElement__sMediaUrl', '') or '', + 'year': item._sYear if hasattr(item, '_sYear') else '', + 'quality': item.getQuality(), + 'description': item.getDescription(), + }) + elif isinstance(item, dict): + entries.append(item) + return entries + +@app.route('/api/sites//hosters') +def api_hosters(site_id): + entry_url = request.args.get('entryUrl', '') + try: + mod = __import__(site_id, globals(), locals()) + func = getattr(mod, 'showHosters', None) + if func: + result = func() + return jsonify({'hosters': result or []}) + return jsonify({'hosters': []}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/resolve') +def api_resolve(): + url = request.args.get('url', '') + title = request.args.get('title', 'video') + download = request.args.get('download', 'false') == 'true' + if not url: + return jsonify({'error': 'no url'}), 400 + try: + import resolveurl as resolver + resolved = resolver.resolve(url) + if resolved: + if download: + did = dm.add(url, title, resolved) + return jsonify({'resolved': resolved, 'download_id': did}) + return jsonify({'resolved': resolved}) + return jsonify({'error': 'could not resolve'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/downloads') +def api_downloads(): + return jsonify(dm.list()) + +@app.route('/api/downloads/') +def api_download_get(did): + d = dm.get(did) + if not d: + return jsonify({'error': 'not found'}), 404 + return jsonify(d) + +@app.route('/api/downloads//file') +def api_download_file(did): + d = dm.get(did) + if not d or d['status'] != 'complete': + return jsonify({'error': 'not ready'}), 404 + return send_file(d['path'], as_attachment=True) + +@app.route('/') +def index(): + return send_file('web/index.html') + +if __name__ == '__main__': + print(f"xStream Downloader") + print(f" Base: {BASE}") + print(f" Listening on http://localhost:8765") + serve(app, host='0.0.0.0', port=8765) diff --git a/cookies/.txt b/cookies/.txt new file mode 100644 index 0000000..e69de29 diff --git a/deps/kodi_six b/deps/kodi_six new file mode 120000 index 0000000..d088fd6 --- /dev/null +++ b/deps/kodi_six @@ -0,0 +1 @@ +../../dependencies/script.module.kodi-six \ No newline at end of file diff --git a/deps/pyaes b/deps/pyaes new file mode 120000 index 0000000..13b20e1 --- /dev/null +++ b/deps/pyaes @@ -0,0 +1 @@ +../../dependencies/script.module.pyaes \ No newline at end of file diff --git a/deps/requests b/deps/requests new file mode 120000 index 0000000..bd0939d --- /dev/null +++ b/deps/requests @@ -0,0 +1 @@ +../../dependencies/script.module.requests \ No newline at end of file diff --git a/deps/resolveurl b/deps/resolveurl new file mode 120000 index 0000000..1c7aa33 --- /dev/null +++ b/deps/resolveurl @@ -0,0 +1 @@ +../../dependencies/script.module.resolveurl \ No newline at end of file diff --git a/deps/six b/deps/six new file mode 120000 index 0000000..610e88a --- /dev/null +++ b/deps/six @@ -0,0 +1 @@ +../../dependencies/script.module.six \ No newline at end of file diff --git a/do_fix.py b/do_fix.py new file mode 100644 index 0000000..e75b106 --- /dev/null +++ b/do_fix.py @@ -0,0 +1,182 @@ +#!/bin/bash +BASE=/home/matthiasberner/Schreibtisch/kodi/xstreamDownloader + +# 1. Fix tools.py - replace with fixed version +cat > "$BASE/xstream/resources/lib/tools.py" << 'ENDOFFILE' +# tools.py - Fixed: all string inputs normalized via _e() +import hashlib, re, os, pyaes, base64 +from urllib.parse import quote, unquote, quote_plus, unquote_plus, urlparse +from html.entities import name2codepoint +from difflib import SequenceMatcher +from functools import lru_cache + +def _e(s): + if s is None: return '' + if isinstance(s, bytes): return s.decode('utf-8') + if not isinstance(s, str): return str(s) + return s + +def platform(): return 'Linux' +def changelog(): pass +def textBox(heading, announce): pass +def infoDialog(message, heading='', icon='', time=5000, sound=False): pass + +class cParser: + @staticmethod + def _get_compiled_pattern(pattern, flags=0): + return re.compile(pattern, flags) + @staticmethod + def _replaceSpecialCharacters(s): + try: + for t in (('\\/', '/'), ('&', '&'), ('\\u00c4', 'Ä'), ('\\u00e4', 'ä'), + ('\\u00d6', 'Ö'), ('\\u00f6', 'ö'), ('\\u00dc', 'Ü'), ('\\u00fc', 'ü'), + ('\\u00df', 'ß'), ('\\u2013', '-'), ('\\u00b2', '²'), ('\\u00b3', '³'), + ('\\u00e9', 'é'), ('\\u2018', "'"), ('\\u201e', '"'), ('\\u201c', '"'), + ('\\u00c9', 'É'), ('\\u2026', '...'), ('\\u202f', 'h'), ('\\u2019', "'"), + ('\\u0308', '̈'), ('\\u00e8', 'è'), ('#038;', ''), ('\\u00f8', 'ø'), + ('/', '/'), ('\\u00e1', 'á'), ('–', '-'), ('“', '"'), ('„', '"'), + ('’', "'"), ('…', '…'), ('\\u00bc', '¼'), ('\\u00bd', '½'), ('\\u00be', '¾'), + ('\\u2153', '⅓'), ('\\u002A', '*')): + s = s.replace(*t) + for h in (('\\/', '/'), ('&', '&'), (''', "'"), ("'", "'"), + ('Ä', 'Ä'), ('ä', 'ä'), ('Ö', 'Ö'), ('ö', 'ö'), + ('Ü', 'Ü'), ('ü', 'ü'), ('ß', 'ß'), ('²', '²'), + ('Ü', '³'), ('¼', '¼'), ('½', '½'), ('¾', '¾'), + ('⅓', '⅓'), ('∗', '*')): + s = s.replace(*h) + except: pass + return s + @staticmethod + def parseSingleResult(sHtmlContent, pattern, ignoreCase=False): + if sHtmlContent: + flags = re.S | re.M + if ignoreCase: flags |= re.I + matches = cParser._get_compiled_pattern(pattern, flags).search(_e(sHtmlContent)) + if matches: + if matches.lastindex is not None and matches.lastindex >= 1: + return True, cParser._replaceSpecialCharacters(matches.group(1)) + return True, cParser._replaceSpecialCharacters(matches.group(0)) + return False, None + @staticmethod + def parse(sHtmlContent, pattern, iMinFoundValue=1, ignoreCase=False): + if sHtmlContent: + flags = re.DOTALL + if ignoreCase: flags |= re.I + aMatches = cParser._get_compiled_pattern(pattern, flags).findall(_e(sHtmlContent)) + if len(aMatches) >= iMinFoundValue: + if isinstance(aMatches[0], tuple): + aMatches = [tuple(cParser._replaceSpecialCharacters(x) if isinstance(x, str) and x is not None else '' for x in match) for match in aMatches] + else: + aMatches = [cParser._replaceSpecialCharacters(x) if isinstance(x, str) and x is not None else '' for x in aMatches] + return True, aMatches + return False, None + @staticmethod + def replace(pattern, sReplaceString, sValue): + return cParser._get_compiled_pattern(pattern).sub(sReplaceString, _e(sValue)) + @staticmethod + def search(pattern, sValue, ignoreCase=True): + flags = re.IGNORECASE if ignoreCase else 0 + return cParser._get_compiled_pattern(pattern, flags).search(_e(sValue)) + @staticmethod + def escape(sValue): return re.escape(_e(sValue)) + @staticmethod + def getNumberFromString(sValue): + aMatches = re.compile(r'\d+').findall(_e(sValue)) + return int(aMatches[0]) if aMatches else 0 + @staticmethod + def urlparse(sUrl): return urlparse(_e(sUrl).replace('www.', '')).netloc.title() + @staticmethod + def urlDecode(sUrl): return unquote(_e(sUrl)) + @staticmethod + def urlEncode(sUrl, safe=''): return quote(_e(sUrl), safe) + @staticmethod + def quote(sUrl): return quote(_e(sUrl)) + @staticmethod + def unquotePlus(sUrl): return unquote_plus(_e(sUrl)) + @staticmethod + def quotePlus(sUrl): return quote_plus(_e(sUrl)) + @staticmethod + def B64decode(text): return base64.b64decode(_e(text)).decode('utf-8') + +class cUtil: + @staticmethod + def removeHtmlTags(sValue, sReplace=''): return re.compile(r'<.*?>').sub(sReplace, _e(sValue)) + @staticmethod + def unescape(text): + def fixup(m): + t = m.group(0) + if not t.endswith(';'): t += ';' + if t[:2] == '&#': + try: + if t[:3] == '&#x': return chr(int(t[3:-1], 16)) + return chr(int(t[2:-1])) + except ValueError: pass + else: + try: return chr(name2codepoint[t[1:-1]]) + except KeyError: pass + return t + t = _e(text) + try: t = t.decode('utf-8') + except: pass + return re.compile(r'&(\w+;|#x?\d+;?)').sub(fixup, t.strip()) + @staticmethod + def cleanse_text(text): return cUtil.removeHtmlTags(text if text else '') + @staticmethod + def evp_decode(cipher_text, passphrase, salt=None): + ct = _e(cipher_text).encode('latin-1') if isinstance(cipher_text, str) else cipher_text + pwd = _e(passphrase).encode('latin-1') if isinstance(passphrase, str) else passphrase + if not salt: salt = ct[8:16]; ct = ct[16:] + else: salt = _e(salt).encode('latin-1') if isinstance(salt, str) else salt + key, iv = cUtil.evpKDF(pwd, salt) + decrypter = pyaes.Decrypter(pyaes.AESModeOfOperationCBC(key, iv)) + plain_text = decrypter.feed(ct) + decrypter.feed() + return plain_text.decode("utf-8") + @staticmethod + def evpKDF(pwd, salt, key_size=32, iv_size=16): + temp = b''; fd = b'' + while len(fd) < key_size + iv_size: + h = hashlib.md5(); h.update(temp + pwd + salt) + temp = h.digest(); fd += temp + return fd[0:key_size], fd[key_size:key_size + iv_size] + @staticmethod + def isSimilar(sSearch, sText, threshold=0.9): + return SequenceMatcher(None, _e(sSearch), _e(sText)).ratio() >= threshold + @staticmethod + @lru_cache(maxsize=200000) + def get_seq_match_ratio(token1, token2): + return SequenceMatcher(None, _e(token1), _e(token2)).ratio() + @staticmethod + def isSimilarByToken(sSearch, sText, threshold=0.9): + tokens_sSearch = _e(sSearch).split() + tokens_sText = _e(sText).split() + if not tokens_sSearch: return False + best_ratios = [max(cUtil.get_seq_match_ratio(token, token2) for token2 in tokens_sText) for token in tokens_sSearch] + return (sum(best_ratios) / len(best_ratios)) >= threshold +ENDOFFILE +echo "tools.py written" + +# 2. Fix requestHandler.py +python3 << 'PYEOF' +with open('/home/matthiasberner/Schreibtisch/kodi/xstreamDownloader/xstream/resources/lib/handler/requestHandler.py') as f: + c = f.read() +old = ' self._sUrl = self.__cleanupUrl(sUrl)' +new = ' if not isinstance(sUrl, str): sUrl = ""\n self._sUrl = self.__cleanupUrl(sUrl)' +if old in c and 'not isinstance' not in c: + c = c.replace(old, new) + with open('/home/matthiasberner/Schreibtisch/kodi/xstreamDownloader/xstream/resources/lib/handler/requestHandler.py', 'w') as f: + f.write(c) + print('requestHandler fixed') +else: + print('requestHandler already fixed or pattern missing') +PYEOF + +# 3. Kill and restart server +fuser -k 8765/tcp 2>/dev/null +sleep 1 +cd /home/matthiasberner/Schreibtisch/kodi/xstreamDownloader +python3 mini_app.py >> /tmp/server.log 2>&1 & +sleep 3 +echo "=== Test ===" +curl -s http://127.0.0.1:8765/api/sites | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Sites OK: {len(d)}')" 2>&1 +curl -s http://127.0.0.1:8765/api/sites/hdfilme/entries | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Entries: {len(d)}')" 2>&1 +tail -8 /tmp/server.log diff --git a/do_fix.sh b/do_fix.sh new file mode 100644 index 0000000..7b18ca4 --- /dev/null +++ b/do_fix.sh @@ -0,0 +1,177 @@ +#!/bin/bash +BASE=/home/matthiasberner/Schreibtisch/kodi/xstreamDownloader + +# 1. Write fixed tools.py +cat > "$BASE/xstream/resources/lib/tools.py" << 'HEREDOC' +# tools.py - Fixed: all string inputs normalized via _e() +import hashlib, re, os, pyaes, base64 +from urllib.parse import quote, unquote, quote_plus, unquote_plus, urlparse +from html.entities import name2codepoint +from difflib import SequenceMatcher +from functools import lru_cache + +def _e(s): + if s is None: return '' + if isinstance(s, bytes): return s.decode('utf-8') + if not isinstance(s, str): return str(s) + return s + +def platform(): return 'Linux' +def changelog(): pass +def textBox(heading, announce): pass +def infoDialog(message, heading='', icon='', time=5000, sound=False): pass + +class cParser: + @staticmethod + def _get_compiled_pattern(pattern, flags=0): return re.compile(pattern, flags) + @staticmethod + def _replaceSpecialCharacters(s): + try: + for t in (('\\/', '/'), ('&', '&'), ('\\u00c4', 'A'), ('\\u00e4', 'a'), + ('\\u00d6', 'O'), ('\\u00f6', 'o'), ('\\u00dc', 'U'), ('\\u00fc', 'u'), + ('\\u00df', 's'), ('\\u2013', '-'), ('\\u00b2', '2'), ('\\u00b3', '3'), + ('\\u00e9', 'e'), ('\\u2018', "'"), ('\\u201e', '"'), ('\\u201c', '"'), + ('\\u00c9', 'E'), ('\\u2026', '...'), ('\\u202f', 'h'), ('\\u2019', "'"), + ('\\u0308', ''), ('\\u00e8', 'e'), ('#038;', ''), ('\\u00f8', 'o'), + ('/', '/'), ('\\u00e1', 'a'), ('–', '-'), ('“', '"'), ('„', '"'), + ('’', "'"), ('…', '...'), ('\\u00bc', '1/4'), ('\\u00bd', '1/2'), ('\\u00be', '3/4'), + ('\\u2153', '1/3'), ('\\u002A', '*')): + s = s.replace(*t) + for h in (('\\/', '/'), ('&', '&'), (''', "'"), ("'", "'"), + ('Ä', 'A'), ('ä', 'a'), ('Ö', 'O'), ('ö', 'o'), + ('Ü', 'U'), ('ü', 'u'), ('ß', 's'), ('²', '2'), + ('¼', '1/4'), ('½', '1/2'), ('¾', '3/4'), + ('⅓', '1/3'), ('∗', '*')): + s = s.replace(*h) + except: pass + return s + @staticmethod + def parseSingleResult(sHtmlContent, pattern, ignoreCase=False): + if sHtmlContent: + flags = re.S | re.M + if ignoreCase: flags |= re.I + matches = cParser._get_compiled_pattern(pattern, flags).search(_e(sHtmlContent)) + if matches: + if matches.lastindex is not None and matches.lastindex >= 1: + return True, cParser._replaceSpecialCharacters(matches.group(1)) + return True, cParser._replaceSpecialCharacters(matches.group(0)) + return False, None + @staticmethod + def parse(sHtmlContent, pattern, iMinFoundValue=1, ignoreCase=False): + if sHtmlContent: + flags = re.DOTALL + if ignoreCase: flags |= re.I + aMatches = cParser._get_compiled_pattern(pattern, flags).findall(_e(sHtmlContent)) + if len(aMatches) >= iMinFoundValue: + if isinstance(aMatches[0], tuple): + aMatches = [tuple(cParser._replaceSpecialCharacters(x) if isinstance(x, str) and x is not None else '' for x in match) for match in aMatches] + else: + aMatches = [cParser._replaceSpecialCharacters(x) if isinstance(x, str) and x is not None else '' for x in aMatches] + return True, aMatches + return False, None + @staticmethod + def replace(pattern, sReplaceString, sValue): return cParser._get_compiled_pattern(pattern).sub(sReplaceString, _e(sValue)) + @staticmethod + def search(pattern, sValue, ignoreCase=True): + flags = re.IGNORECASE if ignoreCase else 0 + return cParser._get_compiled_pattern(pattern, flags).search(_e(sValue)) + @staticmethod + def escape(sValue): return re.escape(_e(sValue)) + @staticmethod + def getNumberFromString(sValue): + aMatches = re.compile(r'\d+').findall(_e(sValue)) + return int(aMatches[0]) if aMatches else 0 + @staticmethod + def urlparse(sUrl): return urlparse(_e(sUrl).replace('www.', '')).netloc.title() + @staticmethod + def urlDecode(sUrl): return unquote(_e(sUrl)) + @staticmethod + def urlEncode(sUrl, safe=''): return quote(_e(sUrl), safe) + @staticmethod + def quote(sUrl): return quote(_e(sUrl)) + @staticmethod + def unquotePlus(sUrl): return unquote_plus(_e(sUrl)) + @staticmethod + def quotePlus(sUrl): return quote_plus(_e(sUrl)) + @staticmethod + def B64decode(text): return base64.b64decode(_e(text)).decode('utf-8') + +class cUtil: + @staticmethod + def removeHtmlTags(sValue, sReplace=''): return re.compile(r'<.*?>').sub(sReplace, _e(sValue)) + @staticmethod + def unescape(text): + def fixup(m): + t = m.group(0) + if not t.endswith(';'): t += ';' + if t[:2] == '&#': + try: + if t[:3] == '&#x': return chr(int(t[3:-1], 16)) + return chr(int(t[2:-1])) + except ValueError: pass + else: + try: return chr(name2codepoint[t[1:-1]]) + except KeyError: pass + return t + t = _e(text) + try: t = t.decode('utf-8') + except: pass + return re.compile(r'&(\w+;|#x?\d+;?)').sub(fixup, t.strip()) + @staticmethod + def cleanse_text(text): return cUtil.removeHtmlTags(text if text else '') + @staticmethod + def evp_decode(cipher_text, passphrase, salt=None): + ct = _e(cipher_text).encode('latin-1') if isinstance(cipher_text, str) else cipher_text + pwd = _e(passphrase).encode('latin-1') if isinstance(passphrase, str) else passphrase + if not salt: salt = ct[8:16]; ct = ct[16:] + else: salt = _e(salt).encode('latin-1') if isinstance(salt, str) else salt + key, iv = cUtil.evpKDF(pwd, salt) + decrypter = pyaes.Decrypter(pyaes.AESModeOfOperationCBC(key, iv)) + plain_text = decrypter.feed(ct) + decrypter.feed() + return plain_text.decode("utf-8") + @staticmethod + def evpKDF(pwd, salt, key_size=32, iv_size=16): + temp = b''; fd = b'' + while len(fd) < key_size + iv_size: + h = hashlib.md5(); h.update(temp + pwd + salt) + temp = h.digest(); fd += temp + return fd[0:key_size], fd[key_size:key_size + iv_size] + @staticmethod + def isSimilar(sSearch, sText, threshold=0.9): return SequenceMatcher(None, _e(sSearch), _e(sText)).ratio() >= threshold + @staticmethod + @lru_cache(maxsize=200000) + def get_seq_match_ratio(token1, token2): return SequenceMatcher(None, _e(token1), _e(token2)).ratio() + @staticmethod + def isSimilarByToken(sSearch, sText, threshold=0.9): + tokens_sSearch = _e(sSearch).split(); tokens_sText = _e(sText).split() + if not tokens_sSearch: return False + best_ratios = [max(cUtil.get_seq_match_ratio(token, token2) for token2 in tokens_sText) for token in tokens_sSearch] + return (sum(best_ratios) / len(best_ratios)) >= threshold +HEREDOC +echo "tools.py written" + +# 2. Fix requestHandler.py +python3 -c " +with open('$BASE/xstream/resources/lib/handler/requestHandler.py') as f: c = f.read() +old = ' self._sUrl = self.__cleanupUrl(sUrl)' +new = ' if not isinstance(sUrl, str): sUrl = \"\"\n self._sUrl = self.__cleanupUrl(sUrl)' +if old in c and 'not isinstance' not in c: + c = c.replace(old, new) + open('$BASE/xstream/resources/lib/handler/requestHandler.py','w').write(c) + print('requestHandler fixed') +else: + print('requestHandler already fixed or pattern missing') +" + +# 3. Kill and restart server +fuser -k 8765/tcp 2>/dev/null +sleep 1 +cd "$BASE" +python3 mini_app.py >> /tmp/server.log 2>&1 & +sleep 4 + +# 4. Test +echo "=== Tests ===" +curl -s http://127.0.0.1:8765/api/sites | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Sites: {len(d)}')" 2>&1 +curl -s http://127.0.0.1:8765/api/sites/hdfilme/entries | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Entries: {len(d)}')" 2>&1 +tail -10 /tmp/server.log diff --git a/fix_and_run.sh b/fix_and_run.sh new file mode 100644 index 0000000..0e2026b --- /dev/null +++ b/fix_and_run.sh @@ -0,0 +1,57 @@ +#!/bin/bash +BASE=/home/matthiasberner/Schreibtisch/kodi/xstreamDownloader + +# 1. Replace tools.py with fixed version +cp "$BASE/xstream/resources/lib/_tools_fixed.py" "$BASE/xstream/resources/lib/tools.py" + +# 2. Fix requestHandler.py - guard sUrl +python3 -c " +with open('$BASE/xstream/resources/lib/handler/requestHandler.py') as f: + c = f.read() +old = ' self._sUrl = self.__cleanupUrl(sUrl)' +new = ' if not isinstance(sUrl, str): sUrl = \"\"\n self._sUrl = self.__cleanupUrl(sUrl)' +if old in c and 'not isinstance' not in c: + c = c.replace(old, new) + with open('$BASE/xstream/resources/lib/handler/requestHandler.py', 'w') as f: + f.write(c) + print('requestHandler fixed') +else: + print('requestHandler already fixed') +" + +# 3. Fix mini_app.py - collecting_gui passthrough + openSettings +python3 -c " +with open('$BASE/mini_app.py') as f: + c = f.read() + +# Fix collecting_gui=False -> collecting_gui +if 'site_mod.load(collecting_gui=False)' in c: + c = c.replace('site_mod.load(collecting_gui=False)', 'site_mod.load(collecting_gui=collecting_gui)') + print('Fixed collecting_gui passthrough') +else: + print('collecting_gui already fixed') + +# Add openSettings to _MockAddon +if 'def openSettings' not in c: + c = c.replace(' def getAddonInfo(self, key):', + ' def openSettings(self, *args, **kwargs):\n pass\n\n def getAddonInfo(self, key):') + print('Added openSettings') + +with open('$BASE/mini_app.py', 'w') as f: + f.write(c) +" + +# 4. Kill old server, start new +fuser -k 8765/tcp 2>/dev/null +sleep 1 +cd "$BASE" +python3 mini_app.py > /tmp/server.log 2>&1 & +SERVER_PID=$! +sleep 3 + +# 5. Test +echo "=== Server PID: $SERVER_PID ===" +curl -s http://127.0.0.1:8765/api/sites | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Sites OK: {len(d)} sites')" 2>&1 +curl -s http://127.0.0.1:8765/api/sites/hdfilme/entries | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Entries: {len(d)}')" 2>&1 +echo "=== Log tail ===" +tail -5 /tmp/server.log diff --git a/mini_app.py b/mini_app.py new file mode 100644 index 0000000..4c2e8c4 --- /dev/null +++ b/mini_app.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +""" +xStream Downloader — Flask App +Nutzt den echten xStream cRequestHandler für HTTP-Requests. +""" +import sys, os, re, io, gzip, hashlib, types, traceback, json, time +from pathlib import Path +from urllib.request import Request, urlopen, HTTPCookieProcessor, build_opener +from urllib.parse import quote, urlencode, urlparse +from urllib.error import HTTPError, URLError +import ssl + +BASE = Path(__file__).parent.resolve() +sys.path.insert(0, str(BASE / 'deps')) +sys.path.insert(0, str(BASE)) + +# ═══════════════════════════════════════════════════════════════ +# MOCK KODI MODULES (in sys.modules vorallen imports) +# ═══════════════════════════════════════════════════════════════ + +def _mkmod(name, **attrs): + class _M(types.ModuleType): + def __getattr__(self, n): + return lambda *a, **k: None + m = _M(name) + for k,v in attrs.items(): setattr(m, k, v) + return m + +class _FakeAddon: + def __init__(self, *a, **kw): + self._id = a[0] if a else 'service.module.resolveurl' + def getAddonInfo(self, k): + return {'id': self._id, 'name': 'xStream', 'version': '1.0.0', + 'icon': '', 'profile': '/tmp', 'author': '', 'description': ''}.get(k, '') + def openSettings(self, *a, **kw): pass + def getSetting(self, k): return '' + def setSetting(self, k, v): pass + def getLocalizedString(self, k): return str(k) + +class _FakeDialog: + def ok(self, *a, **k): pass + def yesno(self, *a, **k): return True + def select(self, *a, **k): return 0 + def notification(self, *a, **k): pass + def browse(self, *a, **k): return '' + +class _FakeDialogBusy: + def create(self, *a, **k): pass + def update(self, *a, **k): pass + def close(self): pass + def isFinished(self): return True + +class _FakeListItem: + def __init__(self, *a, **k): + self._title = str(a[0]) if a else '' + def __getattr__(self, name): + return lambda *a, **k: None + def setInfo(self, *a, **k): pass + def setThumbnail(self, *a, **k): pass + def setIsFolder(self, *a, **k): pass + def getLabel(self): return self._title + def addContextMenuItems(self, *a, **k): pass + +class _FakeWin: + def __init__(self, *a, **k): pass + def doModal(self, *a, **k): pass + def close(self, *a, **k): pass + def clearProperty(self, *a, **k): pass + def setProperty(self, *a, **k): pass + def getProperty(self, *a, **k): return '' + +class _FakePlayer: + def __init__(self): pass + def play(self, *a, **k): pass + def isPlaying(self): return False + def isPlayingVideo(self): return False + def getPlayingFile(self): return '' + def stop(self): pass + +class _FakeMonitor: + def __init__(self, *a, **k): pass + def abortRequested(self): return False + def waitForAbort(self, t=None): return False + +sys.modules['xbmcaddon'] = _mkmod('xbmcaddon', Addon=_FakeAddon) +sys.modules['xbmcgui'] = _mkmod('xbmcgui', + Dialog=_FakeDialog, DialogBusy=_FakeDialogBusy, + Window=_FakeWin, WindowDialog=_FakeWin, WindowXMLDialog=_FakeWin, + ControlButton=_FakeDialog, ControlImage=_FakeDialog, ControlFadeLabel=_FakeDialog, + ListItem=_FakeListItem, + ACTION_MOUSE_LEFT=100, ACTION_PREVIOUS_MENU=10, ACTION_NAV_BACK=92, +) +sys.modules['xbmcvfs'] = _mkmod('xbmcvfs', + translatePath=lambda s: '/tmp', + exists=lambda s: False, mkdir=lambda s: True, mkdirs=lambda s: True, + delete=lambda s: True, rename=lambda s,d: True, listdir=lambda s: ([],[]), + File=type('F',(),{'__enter__':lambda s:s,'__exit__':lambda s,*a:None, + 'read':lambda s,n=-1:b'','write':lambda s,b:len(b),'close':lambda s:None})(), +) +sys.modules['xbmc'] = _mkmod('xbmc', + translatePath=lambda s: '/tmp', log=lambda s,*a: None, + executeJSONRPC=lambda s: '{}', + Player=_FakePlayer, Monitor=_FakeMonitor, + getCondVisibility=lambda s: 0, sleep=lambda s: None, + getInfoLabel=lambda s: '', executebuiltin=lambda s: None, + LOGDEBUG=0, LOGINFO=1, LOGWARNING=2, LOGERROR=3, LOGNONE=4, LOGFATAL=5, +) +sys.modules['xbmcplugin'] = _mkmod('xbmcplugin') +sys.modules['xbmcweb'] = _mkmod('xbmcweb') + +# ═══════════════════════════════════════════════════════════════ +# ECHTER cRequestHandler (nur Standard-Lib + minimale Anpassungen) +# ═══════════════════════════════════════════════════════════════ + +class _FakeConfig: + """cConfig-Ersatz: gibt sichere Defaults zurück.""" + _settings = { + 'cacheTime': '1', 'requestTimeout': '10', + 'bypassDNSlock': 'false', 'volatileHtmlCache': 'false', + } + def getAddonInfo(self, k): + return {'profile': '/tmp', 'id': 'xstream', 'name': 'xStream', + 'version': '1.0.0', 'icon': '', 'author': '', 'description': ''}.get(k, '') + def getSetting(self, k): return self._settings.get(k, '') + def setSetting(self, k, v): self._settings[k] = str(v) + def getLocalizedString(self, k): return f'[{k}]' + +class _FakeCache: + """cCache-Ersatz: kein Memory-Cache, gibt immer None zurück.""" + def get(self, key, cache_time): + return None + def set(self, key, data): + pass + def clear(self): + pass + +class _FakeLogger: + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def error(self, *a, **k): pass + +def _infoDialog(*a, **k): pass + +# ── Standalone cRequestHandler ────────────────────────────────── +class cRequestHandler: + """Echter HTTP-Client, basiert auf dem xStream requestHandler.""" + persistent_openers = {} + + UA_LIST = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15', + ] + + def __init__(self, sUrl, caching=True, ignoreErrors=False, method='GET', + data=None, compression=True, jspost=False, ssl_verify=False): + self._sUrl = str(sUrl) if sUrl else '' + self._sRealUrl = '' + self._USER_AGENT = self.UA_LIST[0] + self._aParameters = {} + self._headerEntries = {} + self._Status = '' + self._sResponseHeader = '' + self._ssl_verify = ssl_verify + self.ignoreDiscard(False) + self.ignoreExpired(False) + self.caching = caching + self.method = method + self.data = data + self.ignoreErrors = ignoreErrors + self.compression = compression + self.jspost = jspost + self.requestTimeout = 15 + self.removeBreakLines(True) + self.removeNewLines(True) + self.__setDefaultHeader() + self.isMemoryCacheActive = False + self._memCache = None + self.__bRemoveNewLines = True + self.__bRemoveBreakLines = True + + def __setDefaultHeader(self): + self.addHeaderEntry('User-Agent', self._USER_AGENT) + self.addHeaderEntry('Accept', '*/*') + self.addHeaderEntry('Accept-Language', 'en-US,en;q=0.5') + self.addHeaderEntry('Accept-Encoding', 'gzip, deflate' if self.compression else 'identity') + + def removeNewLines(self, b): self.__bRemoveNewLines = b + def removeBreakLines(self, b): self.__bRemoveBreakLines = b + + def addHeaderEntry(self, k, v): + self._headerEntries[k] = v + + def addParameters(self, k, v, Quote=False): + self._aParameters[k] = v if not Quote else quote(str(v)) + + def getHeaderEntry(self, k): return self._headerEntries.get(k) + + def getResponseHeader(self): return self._sResponseHeader + def getRealUrl(self): return self._sRealUrl or self._sUrl + def getStatus(self): return self._Status + def getUrl(self): return self._sUrl + + def setUrl(self, url): + self._sUrl = str(url) + return self + + def ignoreDiscard(self, v): pass + def ignoreExpired(self, v): pass + + def getHtml(self, encoding='utf-8'): + """Führt den Request aus und gibt den HTML-Body zurück.""" + if not self._sUrl or self._sUrl == 'False': + return '' + try: + url = self._sUrl + if self._aParameters and self.method == 'GET': + url = url + ('&' if '?' in url else '?') + urlencode(self._aParameters) + + req = Request(url, data=self.data, method=self.method) + for k, v in self._headerEntries.items(): + req.add_header(k, v) + + ctx = ssl.create_default_context() if not self._ssl_verify else None + if self._ssl_verify is False: + ctx = ssl._create_unverified_context() + + opener = build_opener(HTTPCookieProcessor()) + if ctx: + opener.handlers = [h for h in opener.handlers + if not isinstance(h, ssl.SSLHandler)] + [HTTPSHandler(context=ctx)] + + with opener.open(req, timeout=self.requestTimeout) as resp: + self._sRealUrl = resp.geturl() + self._sResponseHeader = str(resp.headers) + data = resp.read() + + if self.compression and 'gzip' in resp.headers.get('Content-Encoding', ''): + try: data = gzip.decompress(data) + except: pass + + text = data.decode(encoding, errors='replace') + + if self.__bRemoveBreakLines: + text = re.sub(r'[\r\n]+', '\n', text) + if self.__bRemoveNewLines: + text = re.sub(r'\n+', ' ', text) + + return text + + except HTTPError as e: + self._Status = str(e.code) + if self.ignoreErrors: return '' + return f'' + except URLError as e: + self._Status = str(e.reason) + if self.ignoreErrors: return '' + return f'' + except Exception as e: + self._Status = str(e) + if self.ignoreErrors: return '' + return f'' + + def getJson(self): + import json + text = self.getHtml() + if text.strip().startswith('' if not self.ignoreErrors else '' + except URLError as e: + self._Status = str(e.reason) + return f'' if not self.ignoreErrors else '' + except Exception as e: + self._Status = str(e) + return f'' if not self.ignoreErrors else '' + + def getJson(self): + import json + text = self.getHtml() + if text.strip().startswith('