Files
xstreamDownloader/app.py
T
matthiasberner 52e39ca751 xStream Downloader updates
- Streaming-Site Auflösung für vidara.to (m3u8 via API)
- HTML-Erkennung für Streaming-Seiten
- Dateiname beim Speichern ohne Prefix
- Speichern-Button deaktiviert nach Klick
- Farbiger Fortschrittsbalken (grün/gelb/rot)
- Status-Text statt ungenauer Prozentanzeige
- Auto-Refresh Timeout bei View-Wechsel abgebrochen
- HLS Download mit korrekten Headern (Referer, User-Agent)
- Diverse Bugfixes und Verbesserungen
2026-07-07 20:21:10 +02:00

1196 lines
45 KiB
Python

#!/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()