Files
xstreamDownloader/mini_app.py
T

712 lines
29 KiB
Python

#!/usr/bin/env python3
"""
xStream Downloader — Flask App
Nutzt den echten xStream cRequestHandler für HTTP-Requests.
"""
import sys, os, re, io, gzip, hashlib, types, traceback, json, time
from pathlib import Path
from urllib.request import Request, urlopen, HTTPCookieProcessor, build_opener
from urllib.parse import quote, urlencode, urlparse
from urllib.error import HTTPError, URLError
import ssl
BASE = Path(__file__).parent.resolve()
sys.path.insert(0, str(BASE / 'deps'))
sys.path.insert(0, str(BASE))
# ═══════════════════════════════════════════════════════════════
# MOCK KODI MODULES (in sys.modules vorallen imports)
# ═══════════════════════════════════════════════════════════════
def _mkmod(name, **attrs):
class _M(types.ModuleType):
def __getattr__(self, n):
return lambda *a, **k: None
m = _M(name)
for k,v in attrs.items(): setattr(m, k, v)
return m
class _FakeAddon:
def __init__(self, *a, **kw):
self._id = a[0] if a else 'service.module.resolveurl'
def getAddonInfo(self, k):
return {'id': self._id, 'name': 'xStream', 'version': '1.0.0',
'icon': '', 'profile': '/tmp', 'author': '', 'description': ''}.get(k, '')
def openSettings(self, *a, **kw): pass
def getSetting(self, k): return ''
def setSetting(self, k, v): pass
def getLocalizedString(self, k): return str(k)
class _FakeDialog:
def ok(self, *a, **k): pass
def yesno(self, *a, **k): return True
def select(self, *a, **k): return 0
def notification(self, *a, **k): pass
def browse(self, *a, **k): return ''
class _FakeDialogBusy:
def create(self, *a, **k): pass
def update(self, *a, **k): pass
def close(self): pass
def isFinished(self): return True
class _FakeListItem:
def __init__(self, *a, **k):
self._title = str(a[0]) if a else ''
def __getattr__(self, name):
return lambda *a, **k: None
def setInfo(self, *a, **k): pass
def setThumbnail(self, *a, **k): pass
def setIsFolder(self, *a, **k): pass
def getLabel(self): return self._title
def addContextMenuItems(self, *a, **k): pass
class _FakeWin:
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 ''
class _FakePlayer:
def __init__(self): 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
class _FakeMonitor:
def __init__(self, *a, **k): pass
def abortRequested(self): return False
def waitForAbort(self, t=None): return False
sys.modules['xbmcaddon'] = _mkmod('xbmcaddon', Addon=_FakeAddon)
sys.modules['xbmcgui'] = _mkmod('xbmcgui',
Dialog=_FakeDialog, DialogBusy=_FakeDialogBusy,
Window=_FakeWin, WindowDialog=_FakeWin, WindowXMLDialog=_FakeWin,
ControlButton=_FakeDialog, ControlImage=_FakeDialog, ControlFadeLabel=_FakeDialog,
ListItem=_FakeListItem,
ACTION_MOUSE_LEFT=100, ACTION_PREVIOUS_MENU=10, ACTION_NAV_BACK=92,
)
sys.modules['xbmcvfs'] = _mkmod('xbmcvfs',
translatePath=lambda s: '/tmp',
exists=lambda s: False, mkdir=lambda s: True, mkdirs=lambda s: True,
delete=lambda s: True, rename=lambda s,d: True, listdir=lambda s: ([],[]),
File=type('F',(),{'__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})(),
)
sys.modules['xbmc'] = _mkmod('xbmc',
translatePath=lambda s: '/tmp', log=lambda s,*a: None,
executeJSONRPC=lambda s: '{}',
Player=_FakePlayer, Monitor=_FakeMonitor,
getCondVisibility=lambda s: 0, sleep=lambda s: None,
getInfoLabel=lambda s: '', executebuiltin=lambda s: None,
LOGDEBUG=0, LOGINFO=1, LOGWARNING=2, LOGERROR=3, LOGNONE=4, LOGFATAL=5,
)
sys.modules['xbmcplugin'] = _mkmod('xbmcplugin')
sys.modules['xbmcweb'] = _mkmod('xbmcweb')
# ═══════════════════════════════════════════════════════════════
# ECHTER cRequestHandler (nur Standard-Lib + minimale Anpassungen)
# ═══════════════════════════════════════════════════════════════
class _FakeConfig:
"""cConfig-Ersatz: gibt sichere Defaults zurück."""
_settings = {
'cacheTime': '1', 'requestTimeout': '10',
'bypassDNSlock': 'false', 'volatileHtmlCache': 'false',
}
def getAddonInfo(self, k):
return {'profile': '/tmp', 'id': 'xstream', 'name': 'xStream',
'version': '1.0.0', 'icon': '', 'author': '', 'description': ''}.get(k, '')
def getSetting(self, k): return self._settings.get(k, '')
def setSetting(self, k, v): self._settings[k] = str(v)
def getLocalizedString(self, k): return f'[{k}]'
class _FakeCache:
"""cCache-Ersatz: kein Memory-Cache, gibt immer None zurück."""
def get(self, key, cache_time):
return None
def set(self, key, data):
pass
def clear(self):
pass
class _FakeLogger:
def info(self, *a, **k): pass
def debug(self, *a, **k): pass
def error(self, *a, **k): pass
def _infoDialog(*a, **k): pass
# ── Standalone cRequestHandler ──────────────────────────────────
class cRequestHandler:
"""Echter HTTP-Client, basiert auf dem xStream requestHandler."""
persistent_openers = {}
UA_LIST = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
]
def __init__(self, sUrl, caching=True, ignoreErrors=False, method='GET',
data=None, compression=True, jspost=False, ssl_verify=False):
self._sUrl = str(sUrl) if sUrl else ''
self._sRealUrl = ''
self._USER_AGENT = self.UA_LIST[0]
self._aParameters = {}
self._headerEntries = {}
self._Status = ''
self._sResponseHeader = ''
self._ssl_verify = ssl_verify
self.ignoreDiscard(False)
self.ignoreExpired(False)
self.caching = caching
self.method = method
self.data = data
self.ignoreErrors = ignoreErrors
self.compression = compression
self.jspost = jspost
self.requestTimeout = 15
self.removeBreakLines(True)
self.removeNewLines(True)
self.__setDefaultHeader()
self.isMemoryCacheActive = False
self._memCache = None
self.__bRemoveNewLines = True
self.__bRemoveBreakLines = True
def __setDefaultHeader(self):
self.addHeaderEntry('User-Agent', self._USER_AGENT)
self.addHeaderEntry('Accept', '*/*')
self.addHeaderEntry('Accept-Language', 'en-US,en;q=0.5')
self.addHeaderEntry('Accept-Encoding', 'gzip, deflate' if self.compression else 'identity')
def removeNewLines(self, b): self.__bRemoveNewLines = b
def removeBreakLines(self, b): self.__bRemoveBreakLines = b
def addHeaderEntry(self, k, v):
self._headerEntries[k] = v
def addParameters(self, k, v, Quote=False):
self._aParameters[k] = v if not Quote else quote(str(v))
def getHeaderEntry(self, k): return self._headerEntries.get(k)
def getResponseHeader(self): return self._sResponseHeader
def getRealUrl(self): return self._sRealUrl or self._sUrl
def getStatus(self): return self._Status
def getUrl(self): return self._sUrl
def setUrl(self, url):
self._sUrl = str(url)
return self
def ignoreDiscard(self, v): pass
def ignoreExpired(self, v): pass
def getHtml(self, encoding='utf-8'):
"""Führt den Request aus und gibt den HTML-Body zurück."""
if not self._sUrl or self._sUrl == 'False':
return ''
try:
url = self._sUrl
if self._aParameters and self.method == 'GET':
url = url + ('&' if '?' in url else '?') + urlencode(self._aParameters)
req = Request(url, data=self.data, method=self.method)
for k, v in self._headerEntries.items():
req.add_header(k, v)
ctx = ssl.create_default_context() if not self._ssl_verify else None
if self._ssl_verify is False:
ctx = ssl._create_unverified_context()
opener = build_opener(HTTPCookieProcessor())
if ctx:
opener.handlers = [h for h in opener.handlers
if not isinstance(h, ssl.SSLHandler)] + [HTTPSHandler(context=ctx)]
with opener.open(req, timeout=self.requestTimeout) as resp:
self._sRealUrl = resp.geturl()
self._sResponseHeader = str(resp.headers)
data = resp.read()
if self.compression and 'gzip' in resp.headers.get('Content-Encoding', ''):
try: data = gzip.decompress(data)
except: pass
text = data.decode(encoding, errors='replace')
if self.__bRemoveBreakLines:
text = re.sub(r'[\r\n]+', '\n', text)
if self.__bRemoveNewLines:
text = re.sub(r'\n+', ' ', text)
return text
except HTTPError as e:
self._Status = str(e.code)
if self.ignoreErrors: return ''
return f'<!-- HTTPError {e.code} -->'
except URLError as e:
self._Status = str(e.reason)
if self.ignoreErrors: return ''
return f'<!-- URLError {e.reason} -->'
except Exception as e:
self._Status = str(e)
if self.ignoreErrors: return ''
return f'<!-- Error {e} -->'
def getJson(self):
import json
text = self.getHtml()
if text.strip().startswith('<!--'): return {}
try: return json.loads(text)
except: return {}
# ═══════════════════════════════════════════════════════════════
# ECHTES cParser (nur Standard-Lib)
# ═══════════════════════════════════════════════════════════════
class cParser:
@staticmethod
def urlEncode(params):
if isinstance(params, dict):
return urlencode(params)
return quote(str(params) if params else '')
@staticmethod
def parseDouble(source, pattern1, pattern2):
match1 = re.search(pattern1, source, re.S)
if not match1: return []
match2 = re.search(pattern2, source[match1.end():], re.S)
if not match2: return []
return [(match1.group(0), match2.group(0))]
@staticmethod
def parse(source, pattern, flags=0):
return re.findall(pattern, source, flags)
@staticmethod
def replace(pattern, replacement, source, count=0):
return re.sub(pattern, replacement, source, count)
@staticmethod
def searchSingle(result, searchPattern, outputParam=None, source=None):
if source is not None:
match = re.search(searchPattern, source, re.S)
elif result:
match = re.search(searchPattern, str(result), re.S)
else:
return None
if not match: return None
return match.group(1) if outputParam and outputParam <= match.lastindex else match.group(0)
@staticmethod
def htmlParse(html):
"""Extrahiert Text aus HTML-Tags."""
if not html: return []
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.S|re.I)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.S|re.I)
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
return text.split(' ')
# ═══════════════════════════════════════════════════════════════
# ECHTES ParameterHandler
# ═══════════════════════════════════════════════════════════════
class ParameterHandler:
def __init__(self, url=''):
self._params = {}
if url:
if '?' in url:
_, qs = url.split('?', 1)
for part in qs.split('&'):
if '=' in part:
k, v = part.split('=', 1)
self._params[quote(k, safe='')] = quote(v, safe='')
def getValue(self, key='', default=''):
return self._params.get(key, default)
def setParam(self, key, value):
self._params[str(key)] = str(value)
def getAll(self): return self._params
# ═══════════════════════════════════════════════════════════════
# GUI COLLECTOR (sammelt addFolder-Aufrufe)
# ═══════════════════════════════════════════════════════════════
class _CollectingGui:
"""Ersetzt cGui und sammelt alle addFolder()-Einträge."""
def __init__(self):
self.entries = []
self.params_stack = [{}]
def addFolder(self, oGuiElement, params=None, bIsFolder=True, iTotal=0, isHoster=False, **kw):
# params kann ParameterHandler, dict, oder string sein
url = ''
name = ''
img = ''
fan = ''
desc = ''
if hasattr(oGuiElement, 'getTitle'):
name = oGuiElement.getTitle() or ''
if hasattr(oGuiElement, 'getMediaUrl'):
url = oGuiElement.getMediaUrl() or ''
elif hasattr(oGuiElement, '_sUrl'):
url = oGuiElement._sUrl or ''
if hasattr(oGuiElement, 'getThumbnailImage'):
img = oGuiElement.getThumbnailImage() or ''
if hasattr(oGuiElement, 'getFanart'):
fan = oGuiElement.getFanart() or ''
elif isinstance(oGuiElement, str):
name = oGuiElement
# URL aus params extrahieren
if not url and params:
if hasattr(params, 'getValue'):
url = params.getValue('entryUrl') or params.getValue('sUrl') or ''
elif isinstance(params, dict):
url = params.get('entryUrl') or params.get('sUrl') or ''
if url and not url.startswith(('http', '/')):
url = ''
if name and url:
self.entries.append({
'name': name.strip(),
'url': url,
'img': img,
'fan': fan,
'desc': desc,
})
def showInfo(self, *a, **k): pass
def showError(self, *a, **k): pass
def showKeyBoard(self, *a, **k): return ''
def showNofication(self, *a, **k): pass
def showLanguage(self, *a, **k): pass
def updateDirectory(self, *a, **k): pass
def setEndOfDirectory(self, *a, **k): pass
def setView(self, *a, **k): pass
def createListItem(self, *a, **k): return None
def getControl(self, *a, **k): return None
def doModal(self, *a, **k): pass
def close(self): pass
# ═══════════════════════════════════════════════════════════════
# RESOLVEURL LAZY LOADER
# ═══════════════════════════════════════════════════════════════
_resolver = None
def get_resolver():
global _resolver
if _resolver is None:
import resolveurl
_resolver = resolveurl
return _resolver
# ═══════════════════════════════════════════════════════════════
# SITE SCANNER
# ═══════════════════════════════════════════════════════════════
def scan_sites():
sites_dir = BASE / 'xstream' / 'sites'
result = []
for path in sorted(sites_dir.glob('*.py')):
if path.name.startswith('_'): continue
try:
txt = path.read_text(errors='ignore')
except: 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
url = url_m.group(1) if url_m else ''
has_search = bool(re.search(r'def\s+search\s*\(', txt))
result.append({'identifier': ident, 'name': name, 'url': url, 'hasSearch': has_search})
return result
# ═══════════════════════════════════════════════════════════════
# SITE EXEC IN SUB-PROCESS
# ═══════════════════════════════════════════════════════════════
import multiprocessing
def _run_site_in_subprocess(func_name, ident, url_arg='', timeout=20):
def worker(pipe, func_name, ident, url_arg):
# Mocks in diesem subprocess
sys.modules['xbmcaddon'] = _mkmod('xbmcaddon', Addon=_FakeAddon)
sys.modules['xbmcgui'] = _mkmod('xbmcgui',
Dialog=_FakeDialog, DialogBusy=_FakeDialogBusy,
Window=_FakeWin, WindowDialog=_FakeWin, WindowXMLDialog=_FakeWin,
ListItem=_FakeListItem,
ACTION_MOUSE_LEFT=100, ACTION_PREVIOUS_MENU=10, ACTION_NAV_BACK=92,
)
sys.modules['xbmcvfs'] = _mkmod('xbmcvfs', translatePath=lambda s: '/tmp',
exists=lambda s: False, mkdir=lambda s: True, mkdirs=lambda s: True,
delete=lambda s: True, rename=lambda s,d: True,
File=type('F',(),{'__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})())
sys.modules['xbmc'] = _mkmod('xbmc',
translatePath=lambda s: '/tmp', log=lambda s,*a: None,
executeJSONRPC=lambda s: '{}',
Player=_FakePlayer, Monitor=_FakeMonitor,
getCondVisibility=lambda s: 0, sleep=lambda s: None,
getInfoLabel=lambda s: '', executebuiltin=lambda s: None,
LOGDEBUG=0, LOGINFO=1, LOGWARNING=2, LOGERROR=3, LOGNONE=4,
)
sys.modules['xbmcplugin'] = _mkmod('xbmcplugin')
sys.modules['xbmcweb'] = _mkmod('xbmcweb')
# Die echten Handler einsetzen
sys.modules['cRequestHandler'] = sys.modules.get('cRequestHandler', None)
sys.modules['cParser'] = sys.modules.get('cParser', None)
BASE_PATH = Path('/home/matthiasberner/Schreibtisch/kodi/xstreamDownloader')
site_path = BASE_PATH / 'xstream' / 'sites' / f'{ident}.py'
xstream_base = BASE_PATH / 'xstream'
handler_base = xstream_base / 'resources' / 'lib' / 'handler'
if not site_path.exists():
pipe.send({'error': 'site not found'}); pipe.close(); return
gui = _CollectingGui()
params = ParameterHandler(url_arg) if url_arg else ParameterHandler()
mod = {
'__name__': ident, '__file__': str(site_path),
'sys': sys, 'os': os, 'time': time, 're': re,
'xbmc': sys.modules['xbmc'], 'xbmcgui': sys.modules['xbmcgui'],
'xbmcaddon': sys.modules['xbmcaddon'], 'xbmcvfs': sys.modules['xbmcvfs'],
'xbmcplugin': sys.modules['xbmcplugin'],
'cGui': gui, 'ParameterHandler': ParameterHandler,
'cConfig': _FakeConfig, 'cCache': _FakeCache,
'logger': _FakeLogger(), 'infoDialog': _infoDialog,
'showEntries': None, 'showHosters': None, 'search': None,
}
old_path = list(sys.path)
# WICHTIG: handler_base VOR xstream_base, damit unser standalone cRequestHandler
# VOR dem original requestHandler.py gefunden wird
sys.path[:0] = [str(handler_base), str(xstream_base), str(xstream_base / 'resources' / 'lib')]
try:
code = site_path.read_text(errors='ignore')
exec(compile(code, str(site_path), 'exec'), mod)
func = mod.get(func_name)
if not func:
pipe.send({'error': f'{func_name} not found'}); pipe.close(); return
# Prüfe Signatur
import inspect
try:
sig = inspect.signature(func)
wants_url = any(p.name in ('entryUrl', 'url', 'sUrl', 'pageUrl') for p in sig.parameters.values())
except (ValueError, TypeError):
wants_url = bool(url_arg)
if wants_url and url_arg:
result = func(url_arg)
else:
result = func()
if isinstance(result, list):
pipe.send({'entries': result})
elif hasattr(result, 'entries'):
pipe.send({'entries': result.entries})
else:
pipe.send({'entries': gui.entries})
except Exception as e:
pipe.send({'error': str(e), 'trace': traceback.format_exc()})
finally:
sys.path[:] = old_path
pipe.close()
ctx = multiprocessing.get_context('fork')
parent_conn, child_conn = ctx.Pipe()
p = ctx.Process(target=worker, args=(child_conn, func_name, ident, url_arg))
p.daemon = True
p.start()
p.join(timeout=timeout)
if p.is_alive():
p.terminate(); p.join(timeout=2)
return {'error': 'timeout'}
if parent_conn.poll(timeout=2):
return parent_conn.recv()
return {'error': 'no response'}
# ═══════════════════════════════════════════════════════════════
# FLASK APP
# ═══════════════════════════════════════════════════════════════
from flask import Flask, send_from_directory, jsonify, request, send_file
import threading
app = Flask(__name__, static_folder='web')
DOWNLOADS = {}
dl_lock = threading.Lock()
def _resolve_real(url):
try:
r = get_resolver()
h = r.resolve(url)
return (h, None) if h else (None, 'resolve failed')
except Exception as e:
return (None, str(e))
def _start_download(url, title, dl_id):
import requests
def run():
try:
final_url, err = _resolve_real(url)
if err:
DOWNLOADS[dl_id]['error'] = err; DOWNLOADS[dl_id]['status'] = 'error'
return
r = requests.get(final_url, stream=True, timeout=30,
headers={'User-Agent': 'Mozilla/5.0'})
r.raise_for_status()
total = int(r.headers.get('Content-Length', 0))
dl_path = BASE / 'downloads' / f'{dl_id}.mp4'
dl_path.parent.mkdir(exist_ok=True)
downloaded = 0
with open(dl_path, 'wb') as f:
for chunk in r.iter_content(65536):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total:
DOWNLOADS[dl_id]['progress'] = downloaded / total
DOWNLOADS[dl_id]['status'] = 'done'
DOWNLOADS[dl_id]['path'] = str(dl_path)
DOWNLOADS[dl_id]['progress'] = 1.0
except Exception as e:
DOWNLOADS[dl_id]['error'] = str(e)
DOWNLOADS[dl_id]['status'] = 'error'
threading.Thread(target=run, daemon=True).start()
@app.route('/')
def index():
return send_from_directory('web', 'index.html')
@app.route('/<path:fn>')
def static_files(fn):
return send_from_directory('web', fn)
@app.route('/api/sites')
def api_sites():
return jsonify(scan_sites())
@app.route('/api/sites/<ident>')
def api_site_info(ident):
for s in scan_sites():
if s['identifier'] == ident:
return jsonify(s)
return jsonify({'error': 'site not found'}), 404
@app.route('/api/sites/<ident>/entries')
def api_entries(ident):
url = request.args.get('url', '')
DEFAULTS = {
'hdfilme': 'https://hdfilme1.co/kinofilme-online/',
'fhdfilme': 'https://fhdfilme.stream',
'aniworld': 'https://aniworld.to',
'serienstream': 'https://serienstream.to',
'burningseries': 'https://burningseries.cx',
'filmpalast': 'https://filmpalast.to',
'kinoger': 'https://kinoger.com',
'kkiste': 'https://kkiste.movie',
'netzkino': 'https://netzkino.com',
'moflix-stream': 'https://moflix-stream.com',
'topstreamfilm': 'https://topstreamfilm.com',
'streamcloud': 'https://streamcloud.store',
'megakino': 'https://megakino.com',
'einschalten': 'https://einschalten.io',
'internetarchive': 'https://archive.org',
'animetoast': 'https://animetoast.org',
}
if not url and ident in DEFAULTS:
url = DEFAULTS[ident]
for func in ('showEntries', 'showNeues', 'showLatest', 'showNewest'):
result = _run_site_in_subprocess(func, ident, url)
err = result.get('error', '')
skip = ('not found', 'timeout', 'site not found', 'no response')
if err and any(s in err for s in skip):
continue
break
if 'error' in result and len(result) == 1:
return jsonify(result), 500
return jsonify(result)
@app.route('/api/sites/<ident>/hosters')
def api_hosters(ident):
url = request.args.get('url', '')
if not url:
return jsonify({'error': 'url required'}), 400
result = _run_site_in_subprocess('showHosters', ident, url)
if 'error' in result and len(result) == 1:
return jsonify(result), 500
return jsonify(result)
@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_real(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', 'download')
if not url:
return jsonify({'error': 'url required'}), 400
dl_id = hashlib.md5(f'{url}{title}'.encode()).hexdigest()[:12]
with dl_lock:
if dl_id not in DOWNLOADS:
DOWNLOADS[dl_id] = {'id': dl_id, 'title': title, 'url': url,
'status': 'starting', 'progress': 0.0,
'path': None, 'error': None}
_start_download(url, title, dl_id)
dl = dict(DOWNLOADS[dl_id])
return jsonify(dl)
@app.route('/api/downloads')
def api_downloads():
with dl_lock:
return jsonify(list(DOWNLOADS.values()))
@app.route('/api/downloads/<dl_id>')
def api_download_info(dl_id):
with dl_lock:
if dl_id not in DOWNLOADS:
return jsonify({'error': 'not found'}), 404
return jsonify(dict(DOWNLOADS[dl_id]))
@app.route('/api/downloads/<dl_id>/file')
def api_download_file(dl_id):
with dl_lock:
dl = DOWNLOADS.get(dl_id, {})
path = dl.get('path')
if not path or not os.path.exists(path):
return jsonify({'error': 'not ready'}), 404
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
if __name__ == '__main__':
(BASE / 'downloads').mkdir(exist_ok=True)
print(f'xStream Downloader — http://localhost:8765')
print(f'Sites: {len(scan_sites())}')
app.run(host='0.0.0.0', port=8765, debug=False, threaded=True, use_reloader=False)