Files
xstreamDownloader/app.py
T

372 lines
15 KiB
Python

import sys, os, time, json, logging, hashlib, threading, types
BASE = os.path.dirname(os.path.abspath(__file__))
# ─── Step 1: Set up mocks BEFORE importing anything from xstream ───────────────
# Descriptor that acts as both a base-class AND a factory.
# "class X(xbmc.Player)" needs a class. "xbmc.Player()" needs a callable.
class _PlayerFactory:
def __init__(self, cls, *args, **kwargs):
self._cls = cls
def __call__(self, *a, **k):
return self._cls(*a, **k)
def __get__(self, obj, objtype=None):
return self._cls
class MockPlayer(object):
def __init__(self, *args, **kwargs):
pass
def play(self, *a, **kw): pass
def isPlayingVideo(self): return False
def getPlayingFile(self): return None
def stop(self): pass
def pause(self): pass
def getTotalTime(self): return 0
def getTime(self): return 0, 0
class MockMonitor:
def __init__(self): pass
def abortRequested(self): return False
def waitForAbort(self, *a): time.sleep(a[0] if a else 1)
class MockPlayList:
def __init__(self): pass
def clear(self): pass
def add(self, *a): pass
class MockXbmcguiWindow:
def __init__(self): pass
def getProperty(self, k): return ''
def setProperty(self, k, v): pass
def clearProperty(self, k): pass
def clearProperties(self): pass
class MockXbmcgui:
Window = type('Window', (), {'10000': MockXbmcguiWindow()})()
NOTIFICATION_INFO = 'info'
NOTIFICATION_WARNING = 'warning'
NOTIFICATION_ERROR = 'error'
@staticmethod
def notification(*a, **k): pass
@staticmethod
def ok(*a, **k): return 1
@staticmethod
def select(*a, **k): return 0
@staticmethod
def textviewer(*a, **k): pass
Dialog = type('Dialog', (), {
'ok': staticmethod(lambda *a, **k: True),
'yesno': staticmethod(lambda *a, **k: True),
'select': staticmethod(lambda *a, **k: 0),
'browse': staticmethod(lambda *a, **k: ''),
'notification': staticmethod(lambda *a, **k: None),
'multiselect': staticmethod(lambda *a, **k: []),
})()
ListItem = type('ListItem', (), {'path': ''})
WindowDialog = type('WindowDialog', (), {'__init__': lambda s: None})
WindowXMLDialog = type('WindowXMLDialog', (), {
'__init__': lambda s, *a, **k: None,
'getProperty': lambda s, k: '',
'setProperty': lambda s, k, v: None,
})
ControlButton = type('ControlButton', (), {'__init__': lambda s, *a, **k: None})
ControlImage = type('ControlImage', (), {'__init__': lambda s, *a, **k: None})
ControlFadeLabel = type('ControlFadeLabel', (), {'__init__': lambda s, *a, **k: None})
ACTION_MOVE_DOWN = 4
ACTION_MOVE_UP = 3
ACTION_MOVE_LEFT = 1
ACTION_MOVE_RIGHT = 2
ACTION_NAV_BACK = 92
ACTION_SELECT_ITEM = 7
class MockXbmcplugin:
@staticmethod
def setResolvedUrl(*a): pass
class MockXbmcvfs:
@staticmethod
def translatePath(p): return p.replace('special://home', BASE)
@staticmethod
def mkdirs(p): os.makedirs(p, exist_ok=True)
@staticmethod
def exists(p): return os.path.exists(p)
@staticmethod
def delete(p): os.path.exists(p) and os.remove(p)
@staticmethod
def writeFile(p, d): open(p, 'w').write(d)
@staticmethod
def readFile(p): return open(p).read() if os.path.exists(p) else ''
class MockXbmcaddonAddon:
_settings = {}
def __init__(self, addon_id='plugin.video.xstream', *a, **k):
self._addon_id = addon_id
self._settings = {}
self._info = {
'plugin.video.xstream': {'name': 'xStream', 'id': 'plugin.video.xstream', 'version': '3.8.0', 'path': BASE, 'profile': os.path.join(BASE, '.profile')},
'script.module.resolveurl': {'name': 'ResolveURL', 'id': 'script.module.resolveurl', 'version': '5.1.190', 'path': BASE, 'profile': os.path.join(BASE, '.profile')},
'xbmc.addon': {'name': 'Kodi', 'id': 'xbmc.addon', 'version': '20.3', 'path': BASE, 'profile': os.path.join(BASE, '.profile')},
}
def getAddonInfo(self, k):
return self._info.get(self._addon_id, {'name': 'xStream', 'id': self._addon_id, 'version': '1.0.0', 'path': BASE, 'profile': os.path.join(BASE, '.profile')}).get(k, '')
def getSetting(self, k): return self._settings.get(k, '')
def setSetting(self, k, v): self._settings[k] = v
def getLocalizedString(self, k): return str(k)
def openSettings(self): pass
class MockXbmcaddon:
Addon = MockXbmcaddonAddon
# Create xbmc as a proper module-type object so "class X(xbmc.Player)" works
_xbmc_mod = types.ModuleType('xbmc')
_xbmc_mod.LOGDEBUG = 0
_xbmc_mod.LOGINFO = 1
_xbmc_mod.LOGWARNING = 2
_xbmc_mod.LOGERROR = 3
_xbmc_mod.LOGFATAL = 4
_xbmc_mod.getCondVisibility = lambda *a: False
_xbmc_mod.getInfoLabel = lambda *a: ''
_xbmc_mod.translatePath = lambda *a: os.path.join(BASE, '.xbmc')
_xbmc_mod.Player = _PlayerFactory(MockPlayer) # works as class AND as callable
_xbmc_mod.Monitor = MockMonitor
_xbmc_mod.PlayList = MockPlayList
_xbmc_mod.sleep = lambda ms: time.sleep(ms/1000)
_xbmc_mod.executebuiltin = lambda *a: None
_xbmc_mod.executeJSONRPC = lambda *a: '{}'
_xbmc_mod.log = lambda msg, level=None: None
_xbmc_mod.Keyboard = lambda *a, **k: type('Keyboard', (), {'doModal': lambda s: None, 'isConfirmed': lambda s: False, 'getText': lambda s: ''})()
_xbmc_mod.getSupportedMedia = lambda media: '|.mkv|.mp4|.avi|.mov|.wmv|.ts|.m4v|.flv|.webm|'
sys.modules['xbmc'] = _xbmc_mod
sys.modules['xbmcgui'] = MockXbmcgui
sys.modules['xbmcplugin'] = MockXbmcplugin
sys.modules['xbmcvfs'] = MockXbmcvfs
sys.modules['xbmcaddon'] = MockXbmcaddon
# kodi_six also imports xbmc, xbmcgui, xbmcplugin, xbmcaddon, xbmcvfs
# Provide them via a proxy module
import importlib.util
_kodi_six_lib = os.path.join(BASE, 'deps', 'kodi_six', 'libs')
sys.path.insert(0, _kodi_six_lib)
_spec = importlib.util.spec_from_file_location('kodi_six', os.path.join(_kodi_six_lib, 'kodi_six', '__init__.py'))
_real_ks = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_real_ks)
_kodi_six_mod = types.ModuleType('kodi_six')
_kodi_six_mod.PY2 = _real_ks.PY2
_kodi_six_mod.py2_encode = _real_ks.py2_encode
_kodi_six_mod.py2_decode = _real_ks.py2_decode
_kodi_six_mod.encode_decode = _real_ks.encode_decode
_kodi_six_mod.xbmc = _xbmc_mod
_kodi_six_mod.xbmcgui = MockXbmcgui
_kodi_six_mod.xbmcplugin = MockXbmcplugin
_kodi_six_mod.xbmcaddon = MockXbmcaddon
_kodi_six_mod.xbmcvfs = MockXbmcvfs
sys.modules['kodi_six'] = _kodi_six_mod
# ─── Step 2: Add dependency paths ──────────────────────────────────────────────
# Each script.module.X has its python package in lib/X/
_deps_map = {
'six': ('six', 'lib/six.py'),
'kodi_six': ('kodi_six', 'libs/kodi_six/__init__.py'),
'requests': ('requests', 'lib/requests/__init__.py'),
'resolveurl': ('resolveurl', 'lib/resolveurl/__init__.py'),
'pyaes': ('pyaes', 'lib/pyaes/__init__.py'),
}
for dep, (pkg_name, pkg_path) in _deps_map.items():
p = os.path.join(BASE, 'deps', dep)
if os.path.exists(p):
sys.path.insert(0, p)
lib_p = os.path.join(p, 'lib')
if os.path.exists(lib_p):
sys.path.insert(0, lib_p)
# ─── Step 3: Add xstream plugin path ──────────────────────────────────────────
sys.path.insert(0, os.path.join(BASE, 'xstream'))
# ─── Step 4: Now import xstream modules ────────────────────────────────────────
from resources.lib.config import cConfig
from resources.lib.logger import logger
from resources.lib.cache import cCache
from resources.lib.handler.ParameterHandler import ParameterHandler
from resources.lib.handler.requestHandler import cRequestHandler
from resources.lib.handler.pluginHandler import cPluginHandler
from resources.lib.tools import cParser, cUtil
from resources.lib.gui.guiElement import cGuiElement
from resources.lib.gui.gui import cGui
from resources.lib.gui.hoster import cHosterGui
# ─── Step 5: Flask app ────────────────────────────────────────────────────────
from flask import Flask, jsonify, request, send_file
from waitress import serve
app = Flask(__name__)
app.logger.setLevel(logging.WARNING)
# ─── Download Manager ─────────────────────────────────────────────────────────
class DownloadManager:
def __init__(self):
self.downloads = {}
self.lock = threading.Lock()
self.base_dir = os.path.join(BASE, 'downloads')
os.makedirs(self.base_dir, exist_ok=True)
def add(self, url, filename, resolved_url):
import requests as _req
with self.lock:
did = hashlib.md5(url.encode()).hexdigest()[:8]
self.downloads[did] = {
'id': did, 'title': filename, 'source_url': url, 'url': resolved_url,
'status': 'downloading', 'progress': 0, 'path': None, 'error': None,
'started': time.time(), 'finished': None,
}
t = threading.Thread(target=self._download, args=(did, resolved_url, filename), daemon=True)
t.start()
return did
def _download(self, did, url, filename):
import requests as _req
safe_name = "".join(c for c in filename if c not in '/\\:*?"<>|').strip() or 'video'
filepath = os.path.join(self.base_dir, safe_name)
try:
resp = _req.get(url, stream=True, timeout=60,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'})
resp.raise_for_status()
total = int(resp.headers.get('content-length', 0))
downloaded = 0
with open(filepath, 'wb') as f:
for chunk in resp.iter_content(chunk_size=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
with self.lock:
self.downloads[did].update(status='complete', progress=100, path=filepath, finished=time.time())
except Exception as e:
with self.lock:
self.downloads[did].update(status='error', error=str(e), finished=time.time())
def list(self):
with self.lock:
return list(self.downloads.values())
def get(self, did):
with self.lock:
return self.downloads.get(did)
dm = DownloadManager()
# ─── API Routes ────────────────────────────────────────────────────────────────
@app.route('/api/sites')
def api_sites():
try:
ph = cPluginHandler()
plugins = ph.getAvailablePlugins()
return jsonify(plugins)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/sites/<site_id>/entries')
def api_entries(site_id):
url = request.args.get('url', '')
search = request.args.get('search', '')
try:
mod = __import__(site_id, globals(), locals())
if search:
URL_SEARCH = getattr(mod, 'URL_SEARCH', None)
if URL_SEARCH:
url = URL_SEARCH % cParser.quotePlus(search)
func = getattr(mod, '_search', None) or getattr(mod, 'showSearch', None)
elif url:
func = getattr(mod, 'showEntries', None)
else:
func = getattr(mod, 'load', None)
if func:
result = func(url)
return jsonify({'entries': _entries_to_dict(result)})
return jsonify({'entries': []})
except Exception as e:
return jsonify({'error': str(e)}), 500
def _entries_to_dict(result):
"""Convert site function results to a list of entry dicts."""
if not result:
return []
entries = []
for item in result:
if isinstance(item, cGuiElement):
entries.append({
'title': item.getTitle(),
'thumb': item.getThumbnail(),
'url': getattr(item, '_cGuiElement__sMediaUrl', '') or '',
'year': item._sYear if hasattr(item, '_sYear') else '',
'quality': item.getQuality(),
'description': item.getDescription(),
})
elif isinstance(item, dict):
entries.append(item)
return entries
@app.route('/api/sites/<site_id>/hosters')
def api_hosters(site_id):
entry_url = request.args.get('entryUrl', '')
try:
mod = __import__(site_id, globals(), locals())
func = getattr(mod, 'showHosters', None)
if func:
result = func()
return jsonify({'hosters': result or []})
return jsonify({'hosters': []})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/resolve')
def api_resolve():
url = request.args.get('url', '')
title = request.args.get('title', 'video')
download = request.args.get('download', 'false') == 'true'
if not url:
return jsonify({'error': 'no url'}), 400
try:
import resolveurl as resolver
resolved = resolver.resolve(url)
if resolved:
if download:
did = dm.add(url, title, resolved)
return jsonify({'resolved': resolved, 'download_id': did})
return jsonify({'resolved': resolved})
return jsonify({'error': 'could not resolve'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/downloads')
def api_downloads():
return jsonify(dm.list())
@app.route('/api/downloads/<did>')
def api_download_get(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['status'] != 'complete':
return jsonify({'error': 'not ready'}), 404
return send_file(d['path'], as_attachment=True)
@app.route('/')
def index():
return send_file('web/index.html')
if __name__ == '__main__':
print(f"xStream Downloader")
print(f" Base: {BASE}")
print(f" Listening on http://localhost:8765")
serve(app, host='0.0.0.0', port=8765)