372 lines
15 KiB
Python
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)
|