#!/usr/bin/env python3 """ xStream Downloader - Web-Wrapper fuer das xstream Kodi-Addon. Architektur (XBMC-konform): - Kodi-Module (xbmc/xbmcgui/xbmcplugin/xbmcvfs/xbmcaddon) werden gemockt BEVOR xstream-Code geladen wird - genau wie XBPython::PyImport_ExtendInittab. - Site-Plugins werden in Subprozessen ausgefuehrt mit eigenem sys.argv und eigenem Plugin-Handle (wie ein neuer CPluginDirectory-Aufruf in Kodi). - xbmcplugin.addDirectoryItem() sammelt Items in einer thread-lokalen Liste, die am Ende an Flask zurueckgegeben wird. Start: python3 app.py URL: http://localhost:8765 """ import os import sys import re import json import time import hashlib import types import threading import traceback import multiprocessing from pathlib import Path from urllib.parse import urlencode, quote, quote_plus, urlparse BASE = Path(__file__).parent.resolve() # ============================================================================ # 1. XBMC MODULE MOCKS - registriert BEVOR xstream-Imports passieren # ============================================================================ def _make_xbmcaddon(): mod = types.ModuleType('xbmcaddon') ADDON_DB = { 'plugin.video.xstream': { 'id': 'plugin.video.xstream', 'name': 'xStream', 'version': '3.8.0', 'icon': '', 'profile': str(BASE), 'author': 'xStream', 'description': '', 'path': str(BASE), }, 'script.module.resolveurl': { 'id': 'script.module.resolveurl', 'name': 'ResolveURL', 'version': '5.1.190', 'icon': '', 'profile': str(BASE), 'author': 'ResolveURL', 'description': '', 'path': str(BASE), }, # resolveurl/lib/kodi.py ruft float(xbmcaddon.Addon('xbmc.addon') # .getAddonInfo('version')[:4]) - das verlangt eine 2-stellige # float-konforme Versionsnummer wie '20.3'. 'xbmc.addon': { 'id': 'xbmc.addon', 'name': 'Kodi', 'version': '20.3', 'icon': '', 'profile': str(BASE), 'author': 'XBMC', 'description': 'Kodi', 'path': str(BASE), }, } class _Addon: def __init__(self, addon_id=''): self._id = addon_id or 'plugin.video.xstream' def getAddonInfo(self, k): return ADDON_DB.get(self._id, ADDON_DB['plugin.video.xstream']).get(k, '') def getSetting(self, k): return '' def setSetting(self, k, v): pass def openSettings(self): pass def getLocalizedString(self, k): # Deutsche xStream-Language-Map (wichtigste Menu-IDs) _LANG = { 30104: 'Alle', 30122: 'Beliebt', 30123: 'Neu', 30128: 'Top', 30281: 'Suche', 30282: 'Seite waehlen (1-', 30284: 'Seite ', 30285: ' von ', 30286: '', 30287: 'Seite', 30500: 'Neu', 30501: 'Aktuelle Releases', 30502: 'Filme', 30506: 'Genre', 30509: 'Jahr', 30510: 'Bewertung', 30511: 'Serien', 30512: 'Staffel', 30513: 'Episode', 30514: 'Neue Serien', 30516: 'Neue Folgen', 30517: 'A-Z', 30518: 'Alle Serien', 30519: 'Beliebte Animes', 30520: 'Suche', 30533: 'Filme', 30538: 'Land', 30541: 'Dokumentation', 30542: 'Familie', 30543: 'Reality', 30554: 'Action', 30555: 'Abenteuer', 30556: 'Anime', 30557: 'Komoedie', 30558: 'Drama', 30559: 'Fantasy', 30560: 'Horror', 30561: 'Neue Animes', 30563: 'Jahr eingeben', 30564: 'Jahr-Suche', 30813: 'Neues', 30814: 'A-Z', 30815: 'Genres', 30817: 'Neue Episoden', 30825: 'Berechne Captcha...', 30826: 'Captcha fehlgeschlagen', 30002: 'Filme', 30003: 'Serien', 30008: 'Alphabetic', 30009: 'Genre', 30010: 'Jahr', 30018: 'Suche', 30025: 'Einstellungen', 30026: 'Aufloesung', 30082: 'Shows', 30100: 'Filme', 30101: 'Serien', 30110: 'Naechste Seite', 30111: 'Vorherige Seite', 30200: 'Aufloesung', 30201: 'German', 30209: 'Staffel', } try: ki = int(k) return _LANG.get(ki, f'[{ki}]') except (ValueError, TypeError): return f'[{k}]' mod.Addon = _Addon return mod def _make_xbmcvfs(): mod = types.ModuleType('xbmcvfs') mod.translatePath = lambda p: str(p).replace('special://home', str(BASE)) mod.exists = lambda p: os.path.exists(str(p)) mod.mkdir = lambda p: os.makedirs(str(p), exist_ok=True) or True mod.mkdirs = lambda p: os.makedirs(str(p), exist_ok=True) or True mod.delete = lambda p: (os.remove(str(p)) if os.path.exists(str(p)) else True) mod.rename = lambda *a: True mod.listdir = lambda p: ([], []) mod.File = type('File', (), { '__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, })() return mod def _make_xbmc(): mod = types.ModuleType('xbmc') mod.LOGDEBUG = 0 mod.LOGINFO = 1 mod.LOGWARNING = 2 mod.LOGERROR = 3 mod.LOGFATAL = 4 mod.LOGNONE = 5 mod.LOGSEVERE = 4 # alias for LOGFATAL (used by resolveurl) mod.translatePath = lambda p: str(p).replace('special://home', str(BASE)) mod.log = lambda msg, level=0: None mod.getCondVisibility = lambda c: 0 mod.getInfoLabel = lambda c: '' mod.sleep = lambda ms: time.sleep(ms / 1000.0) mod.executebuiltin = lambda cmd: None mod.executeJSONRPC = lambda cmd: '{}' mod.getSupportedMedia = lambda m: '|.mkv|.mp4|.avi|.mov|.wmv|.ts|.m4v|.flv|.webm|' class _Keyboard: def __init__(self, default='', heading='', hidden=False): self._text = default def doModal(self, autoclose=0): pass def isConfirmed(self): return False def getText(self): return self._text mod.Keyboard = _Keyboard class _Player: def __init__(self, *a, **k): 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 mod.Player = _Player class _Monitor: def __init__(self, *a, **k): pass def abortRequested(self): return False def waitForAbort(self, t=None): time.sleep(t if t else 1) mod.Monitor = _Monitor class _PlayList: def __init__(self, *a, **k): pass def clear(self): pass def add(self, *a, **k): pass mod.PlayList = _PlayList class _Actor: def __init__(self, name='', role='', thumbnail=''): self.name = name self.role = role self.thumbnail = thumbnail mod.Actor = _Actor return mod def _make_xbmcgui(): mod = types.ModuleType('xbmcgui') class _ListItem: def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path=''): self._label = label self._props = {} self._art = {} self._path = path self._context = [] self._info = {} self._vtag = types.SimpleNamespace( setMediaType=lambda *a, **k: None, setTitle=lambda *a, **k: None, setPlot=lambda *a, **k: None, setYear=lambda *a, **k: None, setSeason=lambda *a, **k: None, setEpisode=lambda *a, **k: None, setTvShowTitle=lambda *a, **k: None, setCast=lambda *a, **k: None, setCountries=lambda *a, **k: None, setDateAdded=lambda *a, **k: None, setDirectors=lambda *a, **k: None, setDuration=lambda *a, **k: None, setRating=lambda *a, **k: None, setGenres=lambda *a, **k: None, setUniqueID=lambda *a, **k: None, setOriginalTitle=lambda *a, **k: None, setTrailer=lambda *a, **k: None, setTagLine=lambda *a, **k: None, setPremiered=lambda *a, **k: None, ) def setInfo(self, t, i): self._info.update(i) def getLabel(self): return self._label def setLabel(self, l): self._label = l def setArt(self, a): self._art.update(a) def setProperty(self, k, v): self._props[k] = v def setPath(self, p): self._path = p def setIsFolder(self, b): self._isFolder = bool(b) def addContextMenuItems(self, items, replace=False): self._context.extend(items) def getVideoInfoTag(self): return self._vtag def getProperty(self, k): return self._props.get(k, '') def getArt(self, k): return self._art.get(k, '') def getPath(self): return self._path mod.ListItem = _ListItem mod.ControlButton = type('ControlButton', (), {'__init__': lambda s, *a, **k: None}) mod.ControlImage = type('ControlImage', (), {'__init__': lambda s, *a, **k: None}) mod.ControlFadeLabel = type('ControlFadeLabel', (), {'__init__': lambda s, *a, **k: None}) class _Win: 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 '' mod.Window = _Win mod.WindowDialog = _Win mod.WindowXMLDialog = _Win class _Dialog: def ok(self, *a, **k): return True def yesno(self, *a, **k): return True def select(self, *a, **k): return 0 def multiselect(self, *a, **k): return [] def browse(self, *a, **k): return '' def notification(self, *a, **k): pass def numeric(self, *a, **k): return 0 def input(self, *a, **k): return '' mod.Dialog = _Dialog mod.ACTION_MOUSE_LEFT = 100 mod.ACTION_PREVIOUS_MENU = 10 mod.ACTION_NAV_BACK = 92 return mod # ============================================================================ # 2. xbmcplugin MOCK - sammelt alle addDirectoryItem-Calls # ============================================================================ _COLLECTOR_KEY = '__plugin_collector__' def _make_xbmcplugin(): mod = types.ModuleType('xbmcplugin') def _get_collector(): if not hasattr(threading.current_thread(), _COLLECTOR_KEY): setattr(threading.current_thread(), _COLLECTOR_KEY, { 'handle': 0, 'items': [], 'resolved': None, 'category': '', 'content': '', 'sort_methods': [], }) return getattr(threading.current_thread(), _COLLECTOR_KEY) def addDirectoryItem(handle, url, listitem, isFolder=False, totalItems=0): coll = _get_collector() coll['items'].append({ 'url': url, 'title': listitem.getLabel(), 'thumb': listitem.getArt('thumb') or listitem.getArt('poster'), 'icon': listitem.getArt('icon'), 'fanart': listitem.getArt('fanart'), 'isFolder': bool(isFolder), 'info': getattr(listitem, '_info', {}), 'properties': getattr(listitem, '_props', {}), }) def addDirectoryItems(handle, items, totalItems=0): for url, listitem, isFolder in items: addDirectoryItem(handle, url, listitem, isFolder, totalItems) def endOfDirectory(handle, succeeded=True, updateListing=False, cacheToDisc=True): coll = _get_collector() coll['handle'] = handle coll['finished'] = True def setResolvedUrl(handle, succeeded, listitem): coll = _get_collector() if succeeded and listitem: coll['resolved'] = listitem.getPath() or listitem.getLabel() def setContent(handle, content): coll = _get_collector() coll['content'] = content def setPluginCategory(handle, cat): coll = _get_collector() coll['category'] = cat def addSortMethod(handle, *methods): coll = _get_collector() coll['sort_methods'].extend(methods) mod.SORT_METHOD_UNSORTED = 0 mod.SORT_METHOD_LABEL = 1 mod.SORT_METHOD_VIDEO_RATING = 2 mod.SORT_METHOD_DATE = 3 mod.SORT_METHOD_PROGRAM_COUNT = 4 mod.SORT_METHOD_VIDEO_RUNTIME = 5 mod.SORT_METHOD_GENRE = 6 mod.SORT_METHOD_TITLE_IGNORE_THE = 7 mod.addDirectoryItem = addDirectoryItem mod.addDirectoryItems = addDirectoryItems mod.endOfDirectory = endOfDirectory mod.setResolvedUrl = setResolvedUrl mod.setContent = setContent mod.setPluginCategory = setPluginCategory mod.addSortMethod = addSortMethod return mod # ============================================================================ # 3. Mock-Registrierung in sys.modules (MUSS VOR xstream-Imports passieren) # ============================================================================ sys.modules['xbmc'] = _make_xbmc() sys.modules['xbmcgui'] = _make_xbmcgui() sys.modules['xbmcplugin'] = _make_xbmcplugin() sys.modules['xbmcvfs'] = _make_xbmcvfs() sys.modules['xbmcaddon'] = _make_xbmcaddon() # ============================================================================ # 4. Site-Plugin Subprozess-Worker # ============================================================================ def _run_plugin_subprocess(ident, function, params=None, timeout=30): """Fuehrt ident.function(params) in einem Subprozess aus.""" parent_conn, child_conn = multiprocessing.Pipe() def worker(conn, ident, function, params_json): try: # Saubere Umgebung - alles weg, was wir brauchen werden for mod_name in list(sys.modules.keys()): if mod_name.startswith(('xbmc', 'resources', 'kodi_six', 'resolveurl', 'requests', 'six')): sys.modules.pop(mod_name, None) # Kodi-Mocks frisch registrieren sys.modules['xbmc'] = _make_xbmc() sys.modules['xbmcgui'] = _make_xbmcgui() sys.modules['xbmcplugin'] = _make_xbmcplugin() sys.modules['xbmcvfs'] = _make_xbmcvfs() sys.modules['xbmcaddon'] = _make_xbmcaddon() # Pfade setzen for sub in ('deps/resolveurl', 'deps/resolveurl/lib', 'deps/requests', 'deps/requests/lib', 'deps/six', 'deps/kodi_six', 'deps/kodi_six/libs', 'deps/pyaes', 'deps/pyaes/lib'): p = str(BASE / sub) if (BASE / sub).exists() and p not in sys.path: sys.path.insert(0, p) sys.path.insert(0, str(BASE)) # for 'from resources.lib...' sys.path.insert(0, str(BASE / 'xstream')) # Plugin-Handle simulieren (xstream-cGui liest sys.argv[1]) # WICHTIG: sys.argv[0] NUR den Plugin-Pfad, ohne Query-Parameter! # Die URL-Generierung in gui.__createItemUrl() baut # pluginPath + ?site=...&function=...&title=... # auf. Wenn sys.argv[0] bereits Query-Parameter hat (wie bei uns), # entsteht eine kaputte URL mit zwei '?'. # Kodi-Standard: sys.argv[0] = 'plugin://plugin.video.xstream/' # ParameterHandler liest aus sys.argv[2] (Query-String) import urllib.parse params_str = '' if params_json: try: pd = json.loads(params_json) if pd: params_str = '?' + urllib.parse.urlencode(pd) except: pass sys.argv = [ 'plugin://plugin.video.xstream/', '1', params_str, ] # Site-Plugin importieren import importlib.util site_path = BASE / 'xstream' / 'sites' / f'{ident}.py' if not site_path.exists(): conn.send({'error': f'Site {ident} not found'}) return spec = importlib.util.spec_from_file_location( f'sites.{ident}', site_path ) mod = importlib.util.module_from_spec(spec) sys.modules[spec.name] = mod params_dict = json.loads(params_json) if params_json else {} mod.PARAM_HANDLER_PARAMS = params_dict spec.loader.exec_module(mod) # Funktion auswaehlen target_func = getattr(mod, function, None) if not target_func: for fallback in ('load', 'showEntries', 'showNeues', 'showSearch', 'search'): f = getattr(mod, fallback, None) if f: target_func = f break if not target_func: conn.send({'error': f'{function} not found in {ident}'}) return # Argumente passend zur Signatur waehlen import inspect try: sig = inspect.signature(target_func) except (ValueError, TypeError): sig = None # Return-Wert einfangen (z.B. showHosters gibt direkte Liste zurueck) func_result = None try: if sig and len(sig.parameters) > 0: param_names = list(sig.parameters.keys()) kwargs = {} for key in param_names: if key in ('sUrl', 'entryUrl', 'url'): kwargs[key] = (params_dict.get('entryUrl') or params_dict.get('sUrl', '')) elif key in ('sSearchText', 'sSearch', 'sTitle'): kwargs[key] = params_dict.get(key, '') func_result = target_func(**kwargs) if kwargs else target_func() else: func_result = target_func() except TypeError: # Fallback: ohne Argumente func_result = target_func() # Gesammelte Items lesen collector = getattr(threading.current_thread(), _COLLECTOR_KEY, None) conn.send({ 'items': collector.get('items', []) if collector else [], 'hosters_return': func_result, # Direkter Return von showHosters etc. 'resolved': collector.get('resolved') if collector else None, 'content': collector.get('content', '') if collector else '', 'finished': collector.get('finished', False) if collector else False, }) except Exception as e: conn.send({'error': str(e), 'trace': traceback.format_exc()}) process = multiprocessing.Process( target=worker, args=(child_conn, ident, function, json.dumps(params or {})) ) process.daemon = True process.start() process.join(timeout=timeout) if process.is_alive(): process.terminate() process.join(timeout=2) if process.is_alive(): process.kill() return {'error': f'timeout after {timeout}s'} if parent_conn.poll(timeout=2): try: return parent_conn.recv() except Exception as e: return {'error': f'recv failed: {e}'} return {'error': 'no response from worker'} # ============================================================================ # 5. Site-Scanner # ============================================================================ SITES_DIR = BASE / 'xstream' / 'sites' def _scan_sites(): result = [] for path in sorted(SITES_DIR.glob('*.py')): if path.name.startswith('_'): continue try: txt = path.read_text(errors='ignore') except Exception: continue ident_m = re.search(r"SITE_IDENTIFIER\s*=\s*['\"]([^'\"]+)['\"]", txt) name_m = re.search(r"SITE_NAME\s*=\s*['\"]([^'\"]+)['\"]", txt) url_m = re.search(r"URL_MAIN\s*=\s*['\"]([^'\"]+)['\"]", txt) ident = ident_m.group(1) if ident_m else path.stem name = name_m.group(1) if name_m else ident.title() url = url_m.group(1) if url_m else '' has_search = bool(re.search(r'def\s+search\s*\(', txt)) or 'URL_SEARCH' in txt result.append({ 'id': ident, 'name': name, 'url': url, 'hasSearch': has_search }) return result # ============================================================================ # 6. URL-Resolver # ============================================================================ def _resolve_streaming_api(url): """Löst Streaming-Sites (vidara.to etc.) auf: Extrahiert filecode und holt m3u8 von API.""" import re, json from urllib.parse import urlparse # Pattern für vidara.to und ähnliche: /e/ parsed = urlparse(url) path_match = re.match(r'/e/([a-zA-Z0-9]+)', parsed.path) if not path_match: return (None, None) # Kein Streaming-Site Pattern filecode = path_match.group(1) base_url = f"{parsed.scheme}://{parsed.netloc}" api_url = f"{base_url}/api/stream" try: import requests resp = requests.post(api_url, json={"filecode": filecode, "device": "web"}, headers={"Content-Type": "application/json", "Referer": url}, timeout=10) resp.raise_for_status() data = resp.json() m3u8_url = data.get('streaming_url') if m3u8_url: return (m3u8_url, None) # API responded but no streaming_url - might be direct embed # Return URL for direct handling instead of failing return (url, None) except requests.exceptions.HTTPError as e: if e.response.status_code == 404: # API endpoint doesn't exist - this is likely a direct embed URL # Return the URL for direct handling return (url, None) return (None, f'Streaming-API Fehler: {e}') except Exception as e: return (None, f'Streaming-API Fehler: {e}') def _resolve_wrapper_site(url): """Löst Wrapper-Sites (meinecloud.click etc.) auf: Fetches HTML und parsed data-link Attribute. Wrapper-Sites wie meinecloud.click geben keine direkten Video-URLs zurück, sondern HTML-Seiten mit eingebetteten Hoster-URLs in data-link Attributen. """ import re from urllib.parse import urlparse # Prüfe ob es eine bekannte Wrapper-Site ist parsed = urlparse(url) host = parsed.netloc.lower() # Wrapper-Site Patterns WRAPPER_PATTERNS = [ r'meinecloud\.click', r'meinedownload\.', r'mycloud\.', ] is_wrapper = any(re.search(p, host) for p in WRAPPER_PATTERNS) if not is_wrapper: return (None, None) # Keine Wrapper-Site # Futtern Sie die HTML-Seite und parsen data-link Attribute try: import requests headers = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', } resp = requests.get(url, headers=headers, timeout=15) resp.raise_for_status() html = resp.text # Parse data-link Attribute # Pattern: data-link="https://host.com/e/filecode" oder data-link="//host.com/e/filecode" links = re.findall(r'data-link="([^"]+)"', html) if not links: return (None, f'Keine data-link URLs in Wrapper-HTML gefunden') # Filtere leere und interne Links valid_links = [] for link in links: if not link or 'meinecloud' in link.lower(): continue # Protokoll-relativ beheben if link.startswith('//'): link = 'https:' + link valid_links.append(link) if not valid_links: return (None, f'Keine validen Hoster-URLs in Wrapper gefunden') # Gebe die erste brauchbare URL zurück (bevorzugt VOE wenn verfügbar) for link in valid_links: if 'voe' in link.lower(): return (link, None) return (valid_links[0], None) except Exception as e: return (None, f'Wrapper-Aufloesung Fehler: {e}') def _resolve_url(url): # 1. Prüfe ob es eine bekannte Streaming-Site ist (/e/ API Pattern) m3u8_url, err = _resolve_streaming_api(url) if m3u8_url: return (m3u8_url, None) if err: # Streaming-Site erkannt aber Fehler return (None, err) # 2. Prüfe ob es eine Wrapper-Site ist (meinecloud.click etc.) wrapper_url, wrapper_err = _resolve_wrapper_site(url) if wrapper_url: return _resolve_url(wrapper_url) # Rekursiv auflösen (könnte wieder Streaming-Site sein) if wrapper_err: # Wrapper erkannt aber Fehler return (None, wrapper_err) # 3. Falls resolveurl nicht verfügbar, URL direkt zurückgeben # (viele URLs sind bereits direkte Stream-URLs) try: import resolveurl r = resolveurl.resolve(url) if r: return (r, None) return (None, 'resolveurl konnte URL nicht aufloesen') except ImportError: # resolveurl nicht verfügbar -> URL direkt verwenden return (url, None) except Exception as e: return (None, str(e)) # ============================================================================ # 7. Download-Manager # ============================================================================ class DownloadManager: def __init__(self): self.downloads = {} self.lock = threading.Lock() self.dir = BASE / 'downloads' self.dir.mkdir(exist_ok=True) def add(self, url, title): with self.lock: did = hashlib.md5( f'{url}{title}{time.time()}'.encode() ).hexdigest()[:12] self.downloads[did] = { 'id': did, 'title': title, 'source_url': url, 'status': 'resolving', 'progress': 0, 'path': None, 'error': None, 'started': time.time(), 'finished': None, } threading.Thread( target=self._run, args=(did, url, title), daemon=True ).start() return did def _run(self, did, url, title): import requests as _req with self.lock: self.downloads[did]['status'] = 'resolving' final_url, err = _resolve_url(url) if err or not final_url: with self.lock: self.downloads[did].update( {'status': 'error', 'error': err or 'no url'} ) return with self.lock: self.downloads[did]['resolved_url'] = final_url # HLS/m3u8 Stream? if '.m3u8' in final_url.lower(): self._run_hls(did, final_url, title) else: self._run_direct(did, final_url, title) def _run_hls(self, did, url, title): """HLS-Stream herunterladen mit ffmpeg""" import subprocess import shutil from urllib.parse import urlparse with self.lock: self.downloads[did]['status'] = 'downloading' safe = re.sub(r'[^\w\-\.]', '_', title)[:80] or 'video' path = self.dir / f'{did}_{safe}.mp4' tmp_ts = self.dir / f'{did}_{safe}.ts' # Extrahiere Domain für Referer Header parsed = urlparse(url) referer = f"{parsed.scheme}://{parsed.netloc}/" ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' # ffmpeg Kommando mit Header für Streaming-Sites cmd = [ shutil.which('ffmpeg') or 'ffmpeg', '-y', # overwrite '-headers', f'Referer: {referer}\r\nUser-Agent: {ua}', '-i', url, '-c', 'copy', # no re-encoding '-bsf:a', 'aac_adtstoasc', # fix audio '-progress', 'pipe:1', str(tmp_ts) ] try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, bufsize=1 ) for line in proc.stdout: line = line.strip() # ffmpeg progress format: "out_time_ms=12345" or "progress=end" if line.startswith('progress=end'): break if '=' in line: key, val = line.split('=', 1) if key == 'out_time_ms' and val: # convert time to percentage (rough estimate) try: ms = int(val) # estimate based on 2hr max for unknown duration estimated_pct = min(ms / (2 * 3600 * 1000) * 100, 99) with self.lock: self.downloads[did]['progress'] = int(estimated_pct) except ValueError: pass proc.wait() if proc.returncode == 0 and tmp_ts.exists(): tmp_ts.rename(path) with self.lock: self.downloads[did].update({ 'status': 'complete', 'progress': 100, 'path': str(path), 'finished': time.time(), }) else: raise Exception(f'ffmpeg exit code: {proc.returncode}') except Exception as e: # Cleanup if tmp_ts.exists(): tmp_ts.unlink() with self.lock: self.downloads[did].update({ 'status': 'error', 'error': str(e), 'finished': time.time(), }) def _run_direct(self, did, url, title): """Direkter Download via requests (mp4/etc.)""" import requests as _req with self.lock: self.downloads[did]['status'] = 'downloading' safe = re.sub(r'[^\w\-\.]', '_', title)[:80] or 'video' path = self.dir / f'{did}_{safe}.mp4' try: r = _req.get( url, stream=True, timeout=30, headers={'User-Agent': 'Mozilla/5.0'} ) r.raise_for_status() content_type = r.headers.get('content-type', '').lower() total = int(r.headers.get('content-length', 0)) downloaded = 0 with open(path, 'wb') as f: for chunk in r.iter_content(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 # Prüfe ob es HTML ist (kein Video) if path.exists() and path.stat().st_size < 50000: with open(path, 'rb') as f: first_bytes = f.read(200) if b'') def static_files(fn): # Erst in web/ suchen web_path = BASE / 'web' / fn if web_path.exists(): return send_from_directory('web', fn) # Fallback: in xstream/resources/art/ suchen art_path = BASE / 'xstream' / 'resources' / 'art' / fn if art_path.exists(): return send_from_directory(BASE / 'xstream' / 'resources' / 'art', fn) # 404 wenn nicht gefunden return '', 404 @app.route('/art/') def art_files(fn): """Serve art files from xstream/resources/art/""" return send_from_directory(BASE / 'xstream' / 'resources' / 'art', fn) @app.route('/api/sites') def api_sites(): return jsonify(_scan_sites()) @app.route('/api/sites//entries') def api_entries(ident): for func in ('load', 'showEntries', 'showNeues'): result = _run_plugin_subprocess( ident, func, params={'entryUrl': ''}, timeout=30 ) if 'items' in result and result['items']: return jsonify({ 'entries': _normalize_items(result['items']), 'source': func }) if result.get('error') and 'timeout' not in result.get('error', ''): continue return jsonify({'entries': [], 'error': 'Keine Eintraege gefunden'}) @app.route('/api/sites//entries/') def api_subentries(ident, entry_url): decoded = request.args.get('url', '') or entry_url # Wenn die URL ein plugin://-Link ist, parsen wir site/function/params if decoded.startswith('plugin://'): parsed = _parse_plugin_url(decoded) if parsed: func = parsed.get('function', 'showEntries') params = parsed.get('params', {}) # sUrl als entryUrl für showEntries if 'sUrl' in params: params['entryUrl'] = params['sUrl'] elif 'sUrl' not in params: params['entryUrl'] = decoded result = _run_plugin_subprocess(ident, func, params=params, timeout=30) if 'error' in result: return jsonify({'error': result['error']}), 500 return jsonify({'entries': _normalize_items(result.get('items', []))}) # Fallback: showEntries mit entryUrl result = _run_plugin_subprocess( ident, 'showEntries', params={'entryUrl': decoded}, timeout=30 ) if 'error' in result: return jsonify({'error': result['error']}), 500 return jsonify({'entries': _normalize_items(result.get('items', []))}) def _parse_plugin_url(url): """Parst eine plugin://-URL und extrahiert site, function, params.""" try: from urllib.parse import parse_qs, urlparse, unquote parsed = urlparse(url) qs = parse_qs(parsed.query, keep_blank_values=True) # Alle Werte sind Listen, wir nehmen das erste Element # Werte muessen dekodiert werden (können doppelt-kodiert sein) def first(v): val = v[0] if v else '' # Zweifach dekodieren wegen doppelter URL-Kodierung try: val = unquote(unquote(val)) except Exception: pass return val site = first(qs.get('site', [])) func = first(qs.get('function', [])) params = {} skip_keys = {'site', 'function'} for k, vals in qs.items(): if k not in skip_keys: params[k] = first(vals) return {'site': site, 'function': func, 'params': params} except Exception: return None @app.route('/api/sites//search') def api_search(ident): query = request.args.get('q', '').strip() if not query: return jsonify({'entries': []}) # Probiere verschiedene Such-Funktionen aus search_funcs = ['search', '_search', 'showSearch', 'SSsearch'] # Erweiterte Liste von Menu-Begriffen die herausgefiltert werden menu_titles = { 'aktuelle releases', 'filme', 'serien', 'genre', 'suche', 'neues', 'beliebte animes', 'genres', 'jahr-suche', 'a-z', 'neueste', 'seasons', 'episoden', 'neue serien', 'neue folgen', 'alphabetisch', 'beliebte', 'neu hinzugefügt', 'menu', 'back', 'zurück', 'start', 'home', 'search results', 'suchergebnisse', 'staffel', 'staffeln', 'folgen', 'anime', 'serien', 'filme', 'serien', 'genre', 'suche', 'neues', 'start', 'beliebt', 'neu', 'a-z', 'genre', 'neueste', 'beliebte', 'jahr', 'abc', 'alle', 'action', 'drama', 'komödie', 'horror', 'sci-fi', 'doku', 'kinoger', 'hdfilme', 'aniworld', 'animeToast', 'burningSeries', 'kinox', 'movie4k', 'streamcloud', 's.to', 'serienstream', } for func_name in search_funcs: result = _run_plugin_subprocess( ident, func_name, params={'sSearchText': query}, timeout=30 ) if 'error' not in result and result.get('items'): items = result.get('items', []) # 1) isFolder=True sind Menu-Einträge -> filtern # 2) Titel die in menu_titles sind -> filtern film_items = [ i for i in items if not i.get('isFolder', False) and i.get('title', '').lower().strip() not in menu_titles ] # Mindestens 3 Zeichen im Titel (keine Kurzbegriffe) film_items = [i for i in film_items if len(i.get('title', '').strip()) > 3] if film_items: return jsonify({'entries': _normalize_items(film_items)}) # Fallback: Direkt-Suche mit requests try: import requests as _req from urllib.parse import urlencode # Site-spezifische Such-URLs search_urls = { 'kinoger': 'https://kinoger.fun/', 'hdfilme': 'https://hdfilme1.co/', 'aniworld': 'https://aniworld.to/', } if ident in search_urls: url = search_urls[ident] data = {'do': 'search', 'subaction': 'search', 'story': query, 'titleonly': '3', 'submit': 'submit'} headers = {'User-Agent': 'Mozilla/5.0', 'Referer': url} resp = _req.post(url, data=data, headers=headers, timeout=30) # Parse results - this is simplified import re pattern = r'
\s*
]*class="img"[^>]*/>\s*([^<]+)' matches = re.findall(pattern, resp.text) entries = [] for link, title in matches[:20]: entries.append({ 'title': title, 'url': f'plugin://plugin.video.xstream/?site={ident}&function=showHosters&entryUrl={link}', 'isFolder': False, }) if entries: return jsonify({'entries': entries}) except Exception as e: pass return jsonify({'entries': [], 'error': 'Keine Suchergebnisse gefunden'}) @app.route('/api/sites//hosters') def api_hosters(ident): url = request.args.get('url', '') if not url: return jsonify({'error': 'url required', 'hosters': []}), 400 # URL kann ein plugin:// URL sein, dann extrahieren wir sUrl bzw. entryUrl daraus hoster_url = url if url.startswith('plugin://'): parsed = _parse_plugin_url(url) if parsed: params = parsed.get('params', {}) # entryUrl ist die Film/Episode-Detailseite (wichtig!) # sUrl ist oft die uebergeordnete Liste hoster_url = params.get('entryUrl') or params.get('sUrl') or url result = _run_plugin_subprocess( ident, 'showHosters', params={'sUrl': hoster_url, 'entryUrl': hoster_url}, timeout=30 ) if 'error' in result: return jsonify({'error': result['error'], 'hosters': []}), 500 raw = result.get('hosters_return') debug_info = { 'hoster_url': hoster_url, 'hosters_return_len': len(raw) if isinstance(raw, list) else 'N/A', 'result_keys': list(result.keys()), } hosters = [] # 1) Direkter Return von showHosters (Liste von Hoster-Dicts) if isinstance(raw, list) and raw: clean = [x for x in raw if isinstance(x, dict)] for h in clean: link_val = h.get('link', '') if isinstance(link_val, list): stream_url = link_val[0] if link_val else '' hoster_name = link_val[1] if len(link_val) > 1 else h.get('name', '') else: stream_url = link_val hoster_name = h.get('name', '') hosters.append({ 'name': hoster_name, 'link': stream_url, 'quality': h.get('quality', ''), 'displayedName': h.get('displayedName', hoster_name), }) return jsonify({'hosters': hosters}) # 2) Fallback: Direkt-Parsing ohne Subprozess (wenn cRequestHandler im Subprozess blockiert wird) if not hosters: try: import requests as _req import re as _re resp = _req.get(hoster_url, timeout=30, headers={ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': hoster_url, }) html = resp.text # Pattern je nach Site if ident == 'kinoger': pattern = r'data-link="([^"]+)"' elif ident == 'hdfilme': pattern = r'link="([^"]+)"' elif ident == 'filmpalast': # filmpalast verwendet data-player-url im Button pattern = r'data-player-url="([^"]+)"' elif ident == 'megakino': # megakino verwendet ähnliches Pattern pattern = r'data-player-url="([^"]+)"' else: pattern = r'data-link="([^"]+)"' matches = _re.findall(pattern, html) debug_info['direct_parse'] = f'{len(matches)} matches' for m in matches: # Baue Hoster-Dict if m.startswith('//'): m = 'https:' + m if m.startswith('/vod/') or 'youtube' in m: continue from urllib.parse import urlparse name = urlparse(m).netloc.split('.')[0] if urlparse(m).netloc else 'unknown' hosters.append({ 'name': name, 'link': m, 'quality': '720', 'displayedName': f'{name} [I][720p][/I]', }) if hosters: debug_info['fallback'] = 'direct_parse' except Exception as e: debug_info['fallback_error'] = str(e) if hosters: return jsonify({'hosters': hosters}) # 3) Fallback: Items aus Collector items = result.get('items', []) for it in items: info = it.get('info', {}) hosters.append({ 'name': it.get('title', 'Unbekannt'), 'link': it.get('url', ''), 'quality': info.get('quality', ''), 'isFolder': it.get('isFolder', False), }) return jsonify({'hosters': hosters, 'debug': debug_info}) @app.route('/api/resolve') def api_resolve(): url = request.args.get('url', '') if not url: return jsonify({'error': 'url required'}), 400 final_url, err = _resolve_url(url) if err: return jsonify({'error': err}), 400 return jsonify({'url': final_url}) @app.route('/api/download') def api_download(): url = request.args.get('url', '') title = request.args.get('title', 'video') if not url: return jsonify({'error': 'url required'}), 400 did = DM.add(url, title) return jsonify(DM.get(did)) @app.route('/api/downloads') def api_downloads(): return jsonify(DM.list()) @app.route('/api/downloads/dir') def api_downloads_dir(): return jsonify({'dir': str(DM.dir)}) @app.route('/api/downloads/') def api_download_info(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.get('status') != 'complete': return jsonify({'error': 'not ready'}), 404 p = d.get('path') if not p or not os.path.exists(p): return jsonify({'error': 'file missing'}), 404 # Sauberer Titel als Dateiname (ohne Prefix) safe_title = re.sub(r'[^\w\-\. ]', '_', d.get('title', 'video'))[:80] download_name = f"{safe_title}.mp4" return send_file(p, as_attachment=True, download_name=download_name) @app.route('/api/downloads//save') def api_download_save(did): """Speichern: Datei senden und aus Liste entfernen""" d = DM.get(did) if not d or d.get('status') != 'complete': return jsonify({'error': 'not ready'}), 404 p = d.get('path') if not p or not os.path.exists(p): return jsonify({'error': 'file missing'}), 404 # Sauberer Titel als Dateiname safe_title = re.sub(r'[^\w\-\. ]', '_', d.get('title', 'video'))[:80] download_name = f"{safe_title}.mp4" # Aus Liste entfernen DM.delete(did) return send_file(p, as_attachment=True, download_name=download_name) @app.route('/api/downloads/', methods=['DELETE']) def api_download_delete(did): if DM.delete(did): return jsonify({'ok': True}) return jsonify({'error': 'not found'}), 404 @app.route('/api/downloads//cancel', methods=['POST']) def api_download_cancel(did): if DM.cancel(did): return jsonify({'ok': True}) return jsonify({'error': 'cannot cancel'}), 400 @app.errorhandler(Exception) def handle_exception(e): return jsonify({ 'error': str(e), 'trace': traceback.format_exc() }), 500 # ============================================================================ # 9. MAIN # ============================================================================ if __name__ == '__main__': print('=' * 60) print(' xStream Downloader (Web-Wrapper)') print(' XBMC-konformer Plugin-Wrapper mit Browser-UI') print('=' * 60) print(f' Sites: {len(_scan_sites())} verfuegbar') print(f' URL: http://localhost:8770') print(f' Downloads: {DM.dir}') print('=' * 60) # SO_REUSEADDR auf Socket-Ebene aktivieren import socket from werkzeug.serving import make_server srv = make_server('0.0.0.0', 8770, app, threaded=True) # Socket-Option: SO_REUSEADDR — erlaubt Bind an orphaned Sockets srv.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) print(' Server startet auf http://localhost:8770') srv.serve_forever()