From ed1ce03b26442959fb647b21d49698e6c8313826 Mon Sep 17 00:00:00 2001 From: DasPoschi Date: Tue, 18 Aug 2026 10:54:08 +0200 Subject: [PATCH] =?UTF-8?q?DAP-75:=20Web=20UI=20f=C3=BCr=20xStream-Suche?= =?UTF-8?q?=20erstellt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone Flask-Web-UI, die die bestehenden xStream-Site-Plugins außerhalb von Kodi ausführt. Kodi-APIs werden durch leichtgewichtige Stubs ersetzt; die vorhandenen _search()-Funktionen aller 23 Sites werden über ThreadPoolExecutor parallel aufgerufen. - webui/kodi_stubs.py – Stubs für xbmc, xbmcaddon, xbmcgui, xbmcplugin, xbmcvfs, resolveurl - webui/app.py – Flask-Server mit /api/search und /api/sites - webui/templates/index.html – Dunkles Kodi-ähnliches Such-UI (Card-Grid, Filter, Detail-Overlay) - webui/requirements.txt – flask, certifi, pyaes, requests Start: cd webui && pip install -r requirements.txt && python app.py Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + webui/app.py | 157 +++++++++++ webui/cache/.gitkeep | 0 webui/kodi_stubs.py | 345 ++++++++++++++++++++++++ webui/requirements.txt | 4 + webui/templates/index.html | 538 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1048 insertions(+) create mode 100644 webui/app.py create mode 100644 webui/cache/.gitkeep create mode 100644 webui/kodi_stubs.py create mode 100644 webui/requirements.txt create mode 100644 webui/templates/index.html diff --git a/.gitignore b/.gitignore index d65c153..04eefb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .paperclip/ +webui/cache/* +!webui/cache/.gitkeep +__pycache__/ +*.pyc diff --git a/webui/app.py b/webui/app.py new file mode 100644 index 0000000..7f84002 --- /dev/null +++ b/webui/app.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +xStream Web UI – standalone Flask application. +Runs the existing xStream site-plugin scrapers outside of Kodi. + +Usage: + pip install flask certifi pyaes + python app.py + open http://localhost:5000 +""" + +import os +import sys +import logging +import importlib.util +import copy +from concurrent.futures import ThreadPoolExecutor, as_completed + +# ── 1. Install Kodi stubs BEFORE any plugin import ─────────────────────────── +sys.path.insert(0, os.path.dirname(__file__)) +import kodi_stubs +kodi_stubs.install() + +# ── 2. Add plugin.video.xstream to import path ─────────────────────────────── +PLUGIN_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'plugin.video.xstream') +) +SITES_DIR = os.path.join(PLUGIN_DIR, 'sites') +sys.path.insert(0, PLUGIN_DIR) + +# ── 3. Import plugin internals (stubs must already be in sys.modules) ───────── +from resources.lib.gui.gui import cGui # noqa: E402 +from resources.lib.config import cConfig # noqa: E402 + +# ── 4. Flask ────────────────────────────────────────────────────────────────── +from flask import Flask, request, jsonify, render_template # noqa: E402 + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('xstream-webui') + +app = Flask(__name__, template_folder='templates') + +# ── Site plugin registry ────────────────────────────────────────────────────── +_SITE_MODULES = {} # identifier → module + +def _load_sites(): + """Import every site plugin that has a _search() function.""" + for fname in sorted(os.listdir(SITES_DIR)): + if not fname.endswith('.py') or fname.startswith('_'): + continue + ident = fname[:-3] + try: + spec = importlib.util.spec_from_file_location( + 'sites.' + ident, + os.path.join(SITES_DIR, fname) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + if hasattr(mod, '_search'): + _SITE_MODULES[ident] = mod + logger.info('Loaded site: %s', getattr(mod, 'SITE_NAME', ident)) + except Exception as exc: + logger.warning('Skipping site %s: %s', ident, exc) + + +# ── Search helpers ──────────────────────────────────────────────────────────── +def _search_site(ident, mod, query): + """Run _search() on one site; return list of result dicts.""" + try: + gui = cGui() + gui._collectMode = True + gui.searchResults = [] + mod._search(gui, query) + results = [] + for item in gui.searchResults: + ge = item['guiElement'] + params = item.get('params') + try: + title = ge.getTitle() + except Exception: + title = '' + result = { + 'title': title, + 'titleSecond': ge.getTitleSecond(), + 'thumbnail': ge.getThumbnail(), + 'fanart': ge.getFanart(), + 'description': ge.getDescription(), + 'year': ge._sYear, + 'mediaType': ge._mediaType, + 'language': ge._sLanguage, + 'quality': ge._sQuality, + 'rating': ge._rating, + 'site': getattr(mod, 'SITE_NAME', ident), + 'siteId': ident, + 'isFolder': item.get('isFolder', True), + } + if params: + try: + result['sUrl'] = params.getValue('sUrl') or '' + except Exception: + result['sUrl'] = '' + results.append(result) + return results + except Exception as exc: + logger.warning('Search error in %s: %s', ident, exc) + return [] + + +# ── Routes ──────────────────────────────────────────────────────────────────── +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/api/sites') +def api_sites(): + sites = [] + for ident, mod in _SITE_MODULES.items(): + sites.append({ + 'id': ident, + 'name': getattr(mod, 'SITE_NAME', ident), + }) + return jsonify(sites) + + +@app.route('/api/search') +def api_search(): + query = request.args.get('q', '').strip() + site_filter = request.args.get('site', '') # optional: restrict to one site + + if not query: + return jsonify({'error': 'No query given', 'results': []}), 400 + + targets = _SITE_MODULES.items() + if site_filter: + targets = [(k, v) for k, v in targets if k == site_filter] + + all_results = [] + with ThreadPoolExecutor(max_workers=8) as pool: + futures = { + pool.submit(_search_site, ident, mod, query): ident + for ident, mod in targets + } + for fut in as_completed(futures): + all_results.extend(fut.result()) + + # Sort: results with thumbnails first, then by title + all_results.sort(key=lambda r: (not bool(r.get('thumbnail')), r.get('title', '').lower())) + + return jsonify({'query': query, 'count': len(all_results), 'results': all_results}) + + +# ── Startup ─────────────────────────────────────────────────────────────────── +if __name__ == '__main__': + _load_sites() + logger.info('Loaded %d sites with search support.', len(_SITE_MODULES)) + app.run(host='0.0.0.0', port=5000, debug=False, threaded=True) diff --git a/webui/cache/.gitkeep b/webui/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/webui/kodi_stubs.py b/webui/kodi_stubs.py new file mode 100644 index 0000000..1418b00 --- /dev/null +++ b/webui/kodi_stubs.py @@ -0,0 +1,345 @@ +# -*- coding: utf-8 -*- +""" +Kodi API stubs for standalone xStream web UI. +Call install() before importing any plugin code. +""" +import sys +import os +import logging +import types +import tempfile + +_log = logging.getLogger('kodi') +_CACHE_DIR = os.path.join(os.path.dirname(__file__), 'cache') +os.makedirs(_CACHE_DIR, exist_ok=True) + + +# ── xbmc ───────────────────────────────────────────────────────────────────── +LOGDEBUG = 0 +LOGINFO = 1 +LOGWARNING = 2 +LOGERROR = 4 +LOGFATAL = 5 +LOGNONE = 6 + + +def _make_xbmc(): + mod = types.ModuleType('xbmc') + mod.LOGDEBUG = LOGDEBUG + mod.LOGINFO = LOGINFO + mod.LOGWARNING = LOGWARNING + mod.LOGERROR = LOGERROR + mod.LOGFATAL = LOGFATAL + mod.LOGNONE = LOGNONE + + def log(msg, level=LOGDEBUG): + if level >= LOGWARNING: + _log.warning('[xbmc] %s', msg) + else: + _log.debug('[xbmc] %s', msg) + mod.log = log + + def getCondVisibility(cond): + if 'linux' in cond.lower() and 'raspberrypi' not in cond.lower(): + return True + return False + mod.getCondVisibility = getCondVisibility + + def sleep(ms): + import time + time.sleep(ms / 1000.0) + mod.sleep = sleep + + def executebuiltin(s): + pass + mod.executebuiltin = executebuiltin + + def translatePath(path): + path = path.replace('special://home/', os.path.expanduser('~') + '/') + path = path.replace('special://profile/', _CACHE_DIR + '/') + path = path.replace('special://temp/', tempfile.gettempdir() + '/') + return path + mod.translatePath = translatePath + + class Monitor: + def abortRequested(self): + return False + def waitForAbort(self, timeout=0): + return False + mod.Monitor = Monitor + return mod + + +# ── xbmcaddon ──────────────────────────────────────────────────────────────── +_ADDON_SETTINGS = { + 'TMDBMETA': 'false', + 'replacefanart': 'false', + 'metaOverwrite': 'false', + 'bypassDNSlock': 'false', + 'volatileHtmlCache': 'false', + 'hosterSelect': 'Auto', + 'blockedHoster': '', + '2captcha.pass': '', +} + +_PLUGIN_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'plugin.video.xstream') +) + + +def _make_xbmcaddon(): + mod = types.ModuleType('xbmcaddon') + + class Addon: + def __init__(self, addon_id='plugin.video.xstream'): + self._id = addon_id + + def getSetting(self, key): + return _ADDON_SETTINGS.get(key, '') + + def setSetting(self, key, value): + _ADDON_SETTINGS[key] = str(value) + + def getAddonInfo(self, key): + infos = { + 'id': 'plugin.video.xstream', + 'version': '2026.02.11', + 'name': 'xStream', + 'path': _PLUGIN_DIR, + 'profile': _CACHE_DIR, + 'icon': '', + 'fanart': '', + } + return infos.get(key, '') + + def getLocalizedString(self, code): + _strings = { + 30166: 'xStream', + 30265: 'Gesperrt', + 30260: 'Diese Seite ist gesperrt.', + 30261: 'Bitte wende dich an deinen Provider.', + 30275: 'Changelog', + 30279: 'Nächste Seite', + 30281: 'Suche', + 30322: 'Entwickleroptionen', + 30500: 'Neu', + 30502: 'Filme', + 30505: 'Dokumentationen', + 30511: 'Serien', + 30520: 'Suche', + } + return _strings.get(code, str(code)) + + def openSettings(self): + pass + + mod.Addon = Addon + return mod + + +# ── xbmcgui ────────────────────────────────────────────────────────────────── +def _make_xbmcgui(): + mod = types.ModuleType('xbmcgui') + mod.NOTIFICATION_INFO = 'info' + mod.NOTIFICATION_WARNING = 'warning' + mod.NOTIFICATION_ERROR = 'error' + + class Dialog: + def ok(self, heading, message): + _log.info('[dialog.ok] %s: %s', heading, message) + def notification(self, heading, message, icon='', time=5000, sound=True): + _log.info('[dialog.notification] %s: %s', heading, message) + def yesno(self, heading, message): + return False + def select(self, heading, items): + return -1 + def input(self, heading, defaultt=''): + return '' + + class Window: + def __init__(self, win_id=0): + pass + def getControl(self, ctrl_id): + return None + + class DialogProgress: + def create(self, heading, message=''): + pass + def update(self, percent, message=''): + pass + def close(self): + pass + def iscanceled(self): + return False + + class DialogProgressBG: + def create(self, heading, message=''): + pass + def update(self, percent=0, heading='', message=''): + pass + def close(self): + pass + def isFinished(self): + return True + + class ListItem: + def __init__(self, label='', label2='', path=''): + self.label = label + def setArt(self, art): + pass + def setInfo(self, type, info): + pass + def setProperty(self, key, value): + pass + def addContextMenuItems(self, items): + pass + def setContentLookup(self, enable): + pass + def setMimeType(self, mime): + pass + + mod.Dialog = Dialog + mod.DialogProgress = DialogProgress + mod.DialogProgressBG = DialogProgressBG + mod.Window = Window + mod.ListItem = ListItem + return mod + + +# ── xbmcplugin ─────────────────────────────────────────────────────────────── +def _make_xbmcplugin(): + mod = types.ModuleType('xbmcplugin') + mod.SORT_METHOD_LABEL = 1 + mod.SORT_METHOD_TITLE = 9 + + def addDirectoryItem(handle, url, listitem, isFolder=False, totalItems=0): + pass + def addDirectoryItems(handle, items, totalItems=0): + pass + def setContent(handle, content): + pass + def addSortMethod(handle, sortMethod): + pass + def setPluginCategory(handle, category): + pass + def endOfDirectory(handle, succeeded=True, updateListing=False, cacheToDisc=True): + pass + def setResolvedUrl(handle, succeeded, listitem): + pass + + mod.addDirectoryItem = addDirectoryItem + mod.addDirectoryItems = addDirectoryItems + mod.setContent = setContent + mod.addSortMethod = addSortMethod + mod.setPluginCategory = setPluginCategory + mod.endOfDirectory = endOfDirectory + mod.setResolvedUrl = setResolvedUrl + return mod + + +# ── xbmcvfs ────────────────────────────────────────────────────────────────── +def _make_xbmcvfs(): + mod = types.ModuleType('xbmcvfs') + + def translatePath(path): + path = path.replace('special://home/', os.path.expanduser('~') + '/') + path = path.replace('special://profile/', _CACHE_DIR + '/') + path = path.replace('special://temp/', tempfile.gettempdir() + '/') + return path + mod.translatePath = translatePath + + def exists(path): + return os.path.exists(path) + def mkdirs(path): + os.makedirs(path, exist_ok=True) + return True + def delete(path): + try: + os.remove(path) + return True + except Exception: + return False + def listdir(path): + try: + entries = os.listdir(path) + dirs = [e for e in entries if os.path.isdir(os.path.join(path, e))] + files = [e for e in entries if os.path.isfile(os.path.join(path, e))] + return dirs, files + except Exception: + return [], [] + def File(path, mode='r'): + return open(path, mode) + + mod.exists = exists + mod.mkdirs = mkdirs + mod.delete = delete + mod.listdir = listdir + mod.File = File + return mod + + +# ── resolveurl (stub – no actual resolution) ───────────────────────────────── +def _make_resolveurl(): + mod = types.ModuleType('resolveurl') + + def relevant_resolvers(domain='', url=''): + return [] + + class HostedMediaFile: + def __init__(self, url='', host='', media_id=''): + self.url = url + def get_resolvers(self): + return [] + def resolve(self): + return self.url + + def resolve(url): + return url + + mod.relevant_resolvers = relevant_resolvers + mod.HostedMediaFile = HostedMediaFile + mod.resolve = resolve + return mod + + +# ── pyaes (lightweight stub if not installed) ───────────────────────────────── +def _ensure_pyaes(): + try: + import pyaes # noqa: F401 + except ImportError: + mod = types.ModuleType('pyaes') + class AESModeOfOperationCBC: + def __init__(self, key, iv=None): + pass + def decrypt(self, data): + return data + class Decrypter: + def __init__(self, aes, padding=None): + pass + def feed(self, data): + return data + class PADDING_NONE: + pass + mod.AESModeOfOperationCBC = AESModeOfOperationCBC + mod.Decrypter = Decrypter + mod.PADDING_NONE = PADDING_NONE + sys.modules['pyaes'] = mod + + +def install(): + """Install all Kodi stubs into sys.modules. Call once at startup.""" + sys.modules.setdefault('xbmc', _make_xbmc()) + sys.modules.setdefault('xbmcaddon', _make_xbmcaddon()) + sys.modules.setdefault('xbmcgui', _make_xbmcgui()) + sys.modules.setdefault('xbmcplugin', _make_xbmcplugin()) + sys.modules.setdefault('xbmcvfs', _make_xbmcvfs()) + sys.modules.setdefault('resolveurl', _make_resolveurl()) + _ensure_pyaes() + + # Some modules import from sub-namespaces + sys.modules.setdefault('xbmc.xbmc', sys.modules['xbmc']) + sys.modules.setdefault('xbmcvfs', sys.modules['xbmcvfs']) + + # Make sure sys.argv has the expected Kodi plugin format + if len(sys.argv) < 3: + sys.argv = ['plugin://plugin.video.xstream/', '1', ''] diff --git a/webui/requirements.txt b/webui/requirements.txt new file mode 100644 index 0000000..916bb15 --- /dev/null +++ b/webui/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0 +certifi>=2024.0 +pyaes>=1.6 +requests>=2.32 diff --git a/webui/templates/index.html b/webui/templates/index.html new file mode 100644 index 0000000..f934d0e --- /dev/null +++ b/webui/templates/index.html @@ -0,0 +1,538 @@ + + + + + + xStream Web UI + + + + +
+ + +
+ +
+ +
+
+
+
+
🎬
+

Suche nach einem Film oder einer Serie

+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ + + +