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)