#!/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/<filecode>
    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)
        return (None, 'streaming_url nicht in API-Antwort')
    except Exception as e:
        return (None, f'Streaming-API Fehler: {e}')


def _resolve_url(url):
    # Erst prüfen ob es eine bekannte Streaming-Site ist
    m3u8_url, err = _resolve_streaming_api(url)
    if m3u8_url:
        return (m3u8_url, None)
    if err:
        # Streaming-Site erkannt aber Fehler
        return (None, err)
    
    # 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'<!DOCTYPE' in first_bytes or b'<html' in first_bytes or b'<script' in first_bytes:
                    path.unlink()
                    raise Exception('Streaming-Seite (HTML), kein Video. Video-Host wird nicht unterstützt.')
            
            with self.lock:
                self.downloads[did].update({
                    'status': 'complete', 'progress': 100,
                    'path': str(path), 'finished': time.time(),
                })
        except Exception as e:
            # Cleanup
            if path.exists():
                try:
                    path.unlink()
                except Exception:
                    pass
            with self.lock:
                self.downloads[did].update({
                    'status': 'error', 'error': str(e),
                    'finished': time.time(),
                })

    def list(self):
        with self.lock:
            return [dict(d) for d in self.downloads.values()]

    def get(self, did):
        with self.lock:
            return dict(self.downloads.get(did, {}))

    def cancel(self, did):
        """Download abbrechen"""
        with self.lock:
            d = self.downloads.get(did)
            if not d:
                return False
            if d['status'] in ('complete', 'error'):
                return False  # already done
            d['status'] = 'cancelled'
            d['finished'] = time.time()
            # Kill associated thread if we can track it
            if did in self._threads:
                self._threads[did]['cancelled'] = True
        return True

    def delete(self, did):
        """Download aus Liste entfernen und Datei löschen"""
        with self.lock:
            d = self.downloads.get(did)
            if not d:
                return False
            # Datei löschen falls vorhanden
            p = d.get('path')
            if p and os.path.exists(p):
                try:
                    os.unlink(p)
                except Exception:
                    pass
            del self.downloads[did]
        return True


# ============================================================================
# 8. Flask-App und API
# ============================================================================
from flask import Flask, jsonify, request, send_file, send_from_directory

app = Flask(__name__, static_folder='web')
app.config['JSON_AS_ASCII'] = False
DM = DownloadManager()


def _normalize_items(items):
    out = []
    for it in items:
        info = it.get('info', {})
        out.append({
            'title': it.get('title', 'Unbenannt'),
            'url': it.get('url', ''),
            'thumb': it.get('thumb') or it.get('icon') or '',
            'fanart': it.get('fanart', ''),
            'isFolder': it.get('isFolder', False),
            'plot': info.get('plot', ''),
            'year': info.get('year', ''),
            'genre': info.get('genre', ''),
            'season': info.get('season', ''),
            'episode': info.get('episode', ''),
            'duration': info.get('duration', ''),
            'rating': info.get('rating', 0),
            'quality': info.get('quality', ''),
        })
    return out


@app.route('/')
def index():
    return send_from_directory('web', 'index.html')


@app.route('/<path:fn>')
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/<path:fn>')
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/<ident>/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/<ident>/entries/<path:entry_url>')
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/<ident>/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'<div class="title">\s*<div class="begin"><img[^>]*class="img"[^>]*/>\s*<a href="([^"]+)">([^<]+)</a>'
            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/<ident>/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/<did>')
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/<did>/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/<did>', 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/<did>/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()