2026-07-07 13:56:09 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
xStream Downloader - Web-Wrapper fuer das xstream Kodi-Addon.
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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.
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
BASE = Path(__file__).parent.resolve()
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
# ============================================================================
|
|
|
|
|
# 1. XBMC MODULE MOCKS - registriert BEVOR xstream-Imports passieren
|
|
|
|
|
# ============================================================================
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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}]'
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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,
|
2026-07-06 14:27:51 +02:00
|
|
|
})()
|
2026-07-07 13:56:09 +02:00
|
|
|
return mod
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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|'
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
class _PlayList:
|
|
|
|
|
def __init__(self, *a, **k): pass
|
|
|
|
|
def clear(self): pass
|
|
|
|
|
def add(self, *a, **k): pass
|
|
|
|
|
mod.PlayList = _PlayList
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
class _Actor:
|
|
|
|
|
def __init__(self, name='', role='', thumbnail=''):
|
|
|
|
|
self.name = name
|
|
|
|
|
self.role = role
|
|
|
|
|
self.thumbnail = thumbnail
|
|
|
|
|
mod.Actor = _Actor
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
return mod
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
def _make_xbmcgui():
|
|
|
|
|
mod = types.ModuleType('xbmcgui')
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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})
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
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
|
|
|
|
|
# ============================================================================
|
2026-07-07 20:21:10 +02:00
|
|
|
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)
|
2026-07-07 22:19:23 +02:00
|
|
|
# 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}')
|
2026-07-07 20:21:10 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
return (None, f'Streaming-API Fehler: {e}')
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 22:19:23 +02:00
|
|
|
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}')
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
def _resolve_url(url):
|
2026-07-07 22:19:23 +02:00
|
|
|
# 1. Prüfe ob es eine bekannte Streaming-Site ist (/e/<filecode> API Pattern)
|
2026-07-07 20:21:10 +02:00
|
|
|
m3u8_url, err = _resolve_streaming_api(url)
|
|
|
|
|
if m3u8_url:
|
|
|
|
|
return (m3u8_url, None)
|
|
|
|
|
if err:
|
|
|
|
|
# Streaming-Site erkannt aber Fehler
|
|
|
|
|
return (None, err)
|
|
|
|
|
|
2026-07-07 22:19:23 +02:00
|
|
|
# 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
|
2026-07-07 20:21:10 +02:00
|
|
|
# (viele URLs sind bereits direkte Stream-URLs)
|
2026-07-07 13:56:09 +02:00
|
|
|
try:
|
|
|
|
|
import resolveurl
|
|
|
|
|
r = resolveurl.resolve(url)
|
|
|
|
|
if r:
|
|
|
|
|
return (r, None)
|
|
|
|
|
return (None, 'resolveurl konnte URL nicht aufloesen')
|
|
|
|
|
except ImportError:
|
2026-07-07 20:21:10 +02:00
|
|
|
# resolveurl nicht verfügbar -> URL direkt verwenden
|
|
|
|
|
return (url, None)
|
2026-07-07 13:56:09 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
return (None, str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# 7. Download-Manager
|
|
|
|
|
# ============================================================================
|
2026-07-06 14:27:51 +02:00
|
|
|
class DownloadManager:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.downloads = {}
|
|
|
|
|
self.lock = threading.Lock()
|
2026-07-07 13:56:09 +02:00
|
|
|
self.dir = BASE / 'downloads'
|
|
|
|
|
self.dir.mkdir(exist_ok=True)
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
def add(self, url, title):
|
2026-07-06 14:27:51 +02:00
|
|
|
with self.lock:
|
2026-07-07 13:56:09 +02:00
|
|
|
did = hashlib.md5(
|
|
|
|
|
f'{url}{title}{time.time()}'.encode()
|
|
|
|
|
).hexdigest()[:12]
|
2026-07-06 14:27:51 +02:00
|
|
|
self.downloads[did] = {
|
2026-07-07 13:56:09 +02:00
|
|
|
'id': did, 'title': title, 'source_url': url,
|
|
|
|
|
'status': 'resolving', 'progress': 0,
|
|
|
|
|
'path': None, 'error': None,
|
2026-07-06 14:27:51 +02:00
|
|
|
'started': time.time(), 'finished': None,
|
|
|
|
|
}
|
2026-07-07 13:56:09 +02:00
|
|
|
threading.Thread(
|
|
|
|
|
target=self._run, args=(did, url, title), daemon=True
|
|
|
|
|
).start()
|
2026-07-06 14:27:51 +02:00
|
|
|
return did
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
def _run(self, did, url, title):
|
2026-07-06 14:27:51 +02:00
|
|
|
import requests as _req
|
2026-07-07 13:56:09 +02:00
|
|
|
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
|
|
|
|
|
|
2026-07-07 20:21:10 +02:00
|
|
|
# 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'
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
safe = re.sub(r'[^\w\-\.]', '_', title)[:80] or 'video'
|
|
|
|
|
path = self.dir / f'{did}_{safe}.mp4'
|
2026-07-06 14:27:51 +02:00
|
|
|
try:
|
2026-07-07 13:56:09 +02:00
|
|
|
r = _req.get(
|
2026-07-07 20:21:10 +02:00
|
|
|
url, stream=True, timeout=30,
|
2026-07-07 13:56:09 +02:00
|
|
|
headers={'User-Agent': 'Mozilla/5.0'}
|
|
|
|
|
)
|
|
|
|
|
r.raise_for_status()
|
2026-07-07 20:21:10 +02:00
|
|
|
content_type = r.headers.get('content-type', '').lower()
|
2026-07-07 13:56:09 +02:00
|
|
|
total = int(r.headers.get('content-length', 0))
|
2026-07-06 14:27:51 +02:00
|
|
|
downloaded = 0
|
2026-07-07 13:56:09 +02:00
|
|
|
with open(path, 'wb') as f:
|
|
|
|
|
for chunk in r.iter_content(65536):
|
2026-07-06 14:27:51 +02:00
|
|
|
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
|
2026-07-07 20:21:10 +02:00
|
|
|
|
|
|
|
|
# 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.')
|
|
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
with self.lock:
|
2026-07-07 13:56:09 +02:00
|
|
|
self.downloads[did].update({
|
|
|
|
|
'status': 'complete', 'progress': 100,
|
|
|
|
|
'path': str(path), 'finished': time.time(),
|
|
|
|
|
})
|
2026-07-06 14:27:51 +02:00
|
|
|
except Exception as e:
|
2026-07-07 20:21:10 +02:00
|
|
|
# Cleanup
|
|
|
|
|
if path.exists():
|
|
|
|
|
try:
|
|
|
|
|
path.unlink()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-07-06 14:27:51 +02:00
|
|
|
with self.lock:
|
2026-07-07 13:56:09 +02:00
|
|
|
self.downloads[did].update({
|
|
|
|
|
'status': 'error', 'error': str(e),
|
|
|
|
|
'finished': time.time(),
|
|
|
|
|
})
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
def list(self):
|
|
|
|
|
with self.lock:
|
2026-07-07 13:56:09 +02:00
|
|
|
return [dict(d) for d in self.downloads.values()]
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
def get(self, did):
|
|
|
|
|
with self.lock:
|
2026-07-07 13:56:09 +02:00
|
|
|
return dict(self.downloads.get(did, {}))
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 20:21:10 +02:00
|
|
|
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
|
|
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
# ============================================================================
|
|
|
|
|
# 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)
|
|
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
@app.route('/api/sites')
|
|
|
|
|
def api_sites():
|
2026-07-07 13:56:09 +02:00
|
|
|
return jsonify(_scan_sites())
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
@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
|
2026-07-06 14:27:51 +02:00
|
|
|
})
|
2026-07-07 13:56:09 +02:00
|
|
|
if result.get('error') and 'timeout' not in result.get('error', ''):
|
|
|
|
|
continue
|
|
|
|
|
return jsonify({'entries': [], 'error': 'Keine Eintraege gefunden'})
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
|
|
|
|
|
@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."""
|
2026-07-06 14:27:51 +02:00
|
|
|
try:
|
2026-07-07 13:56:09 +02:00
|
|
|
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})
|
2026-07-06 14:27:51 +02:00
|
|
|
except Exception as e:
|
2026-07-07 13:56:09 +02:00
|
|
|
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})
|
|
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
@app.route('/api/resolve')
|
|
|
|
|
def api_resolve():
|
|
|
|
|
url = request.args.get('url', '')
|
|
|
|
|
if not url:
|
2026-07-07 13:56:09 +02:00
|
|
|
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))
|
|
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
@app.route('/api/downloads')
|
|
|
|
|
def api_downloads():
|
2026-07-07 13:56:09 +02:00
|
|
|
return jsonify(DM.list())
|
|
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
|
2026-07-07 20:21:10 +02:00
|
|
|
@app.route('/api/downloads/dir')
|
|
|
|
|
def api_downloads_dir():
|
|
|
|
|
return jsonify({'dir': str(DM.dir)})
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
@app.route('/api/downloads/<did>')
|
2026-07-07 13:56:09 +02:00
|
|
|
def api_download_info(did):
|
|
|
|
|
d = DM.get(did)
|
2026-07-06 14:27:51 +02:00
|
|
|
if not d:
|
|
|
|
|
return jsonify({'error': 'not found'}), 404
|
|
|
|
|
return jsonify(d)
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
|
2026-07-06 14:27:51 +02:00
|
|
|
@app.route('/api/downloads/<did>/file')
|
|
|
|
|
def api_download_file(did):
|
2026-07-07 13:56:09 +02:00
|
|
|
d = DM.get(did)
|
|
|
|
|
if not d or d.get('status') != 'complete':
|
2026-07-06 14:27:51 +02:00
|
|
|
return jsonify({'error': 'not ready'}), 404
|
2026-07-07 13:56:09 +02:00
|
|
|
p = d.get('path')
|
|
|
|
|
if not p or not os.path.exists(p):
|
|
|
|
|
return jsonify({'error': 'file missing'}), 404
|
2026-07-07 20:21:10 +02:00
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 22:00:35 +02:00
|
|
|
@app.route('/api/downloads/<did>/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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 20:21:10 +02:00
|
|
|
@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
|
2026-07-06 14:27:51 +02:00
|
|
|
|
|
|
|
|
|
2026-07-07 13:56:09 +02:00
|
|
|
@app.errorhandler(Exception)
|
|
|
|
|
def handle_exception(e):
|
|
|
|
|
return jsonify({
|
|
|
|
|
'error': str(e), 'trace': traceback.format_exc()
|
|
|
|
|
}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# 9. MAIN
|
|
|
|
|
# ============================================================================
|
2026-07-06 14:27:51 +02:00
|
|
|
if __name__ == '__main__':
|
2026-07-07 13:56:09 +02:00
|
|
|
print('=' * 60)
|
|
|
|
|
print(' xStream Downloader (Web-Wrapper)')
|
|
|
|
|
print(' XBMC-konformer Plugin-Wrapper mit Browser-UI')
|
|
|
|
|
print('=' * 60)
|
|
|
|
|
print(f' Sites: {len(_scan_sites())} verfuegbar')
|
2026-07-07 20:21:10 +02:00
|
|
|
print(f' URL: http://localhost:8770')
|
2026-07-07 13:56:09 +02:00
|
|
|
print(f' Downloads: {DM.dir}')
|
|
|
|
|
print('=' * 60)
|
|
|
|
|
# SO_REUSEADDR auf Socket-Ebene aktivieren
|
|
|
|
|
import socket
|
|
|
|
|
from werkzeug.serving import make_server
|
2026-07-07 20:21:10 +02:00
|
|
|
srv = make_server('0.0.0.0', 8770, app, threaded=True)
|
2026-07-07 13:56:09 +02:00
|
|
|
# Socket-Option: SO_REUSEADDR — erlaubt Bind an orphaned Sockets
|
|
|
|
|
srv.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
2026-07-07 20:21:10 +02:00
|
|
|
print(' Server startet auf http://localhost:8770')
|
2026-07-07 13:56:09 +02:00
|
|
|
srv.serve_forever()
|