Files
Xstream-Standalone/webui/app.py
Paperclip CTO 81450723f3 DAP-79: WebUI Stream-Wiedergabe und Download
Erweitert Suchergebnisse um Auflösungsmetadaten, führt die Hoster-Auflösung über /api/resolve aus und stellt Wiedergabe/Download über kurzlebige Stream-Tokens bereit.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-08-21 17:32:43 +02:00

380 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
xStream Web UI standalone Flask application.
Runs the existing xStream site-plugin scrapers outside of Kodi.
Usage:
pip install -r requirements.txt
python app.py
open http://localhost:5000
"""
import os
import sys
import logging
import importlib.util
import copy
import mimetypes
import secrets
import time
from contextlib import contextmanager
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import parse_qsl, urlencode
import requests
# ── 1. Install Kodi stubs BEFORE any plugin import ───────────────────────────
sys.path.insert(0, os.path.dirname(__file__))
import kodi_stubs
kodi_stubs.install()
# ── 2. Add plugin.video.xstream to import path ───────────────────────────────
PLUGIN_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', 'plugin.video.xstream')
)
SITES_DIR = os.path.join(PLUGIN_DIR, 'sites')
sys.path.insert(0, PLUGIN_DIR)
# ── 3. Import plugin internals (stubs must already be in sys.modules) ─────────
from resources.lib.gui.gui import cGui # noqa: E402
from resources.lib.config import cConfig # noqa: E402
# ── 4. Flask ──────────────────────────────────────────────────────────────────
from flask import Flask, request, jsonify, render_template, Response, abort, stream_with_context # noqa: E402
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger('xstream-webui')
app = Flask(__name__, template_folder='templates')
# ── Site plugin registry ──────────────────────────────────────────────────────
_SITE_MODULES = {} # identifier → module
_STREAMS = {} # transient token → resolved stream data
_STREAM_TTL = 60 * 30
def _load_sites():
"""Import every site plugin that has a _search() function."""
for fname in sorted(os.listdir(SITES_DIR)):
if not fname.endswith('.py') or fname.startswith('_'):
continue
ident = fname[:-3]
try:
spec = importlib.util.spec_from_file_location(
'sites.' + ident,
os.path.join(SITES_DIR, fname)
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
if hasattr(mod, '_search'):
_SITE_MODULES[ident] = mod
logger.info('Loaded site: %s', getattr(mod, 'SITE_NAME', ident))
except Exception as exc:
logger.warning('Skipping site %s: %s', ident, exc)
@contextmanager
def _plugin_argv(params):
"""Provide Kodi-style sys.argv query params for site plugin functions."""
old_argv = sys.argv[:]
sys.argv = ['plugin://plugin.video.xstream/', '1', '?' + urlencode(params)]
try:
yield
finally:
sys.argv = old_argv
def _pipe_headers(url):
if '|' not in url:
return url, {}
clean_url, raw_headers = url.split('|', 1)
return clean_url, dict(parse_qsl(raw_headers, keep_blank_values=True))
def _register_stream(url, title):
url, headers = _pipe_headers(url)
token = secrets.token_urlsafe(18)
_STREAMS[token] = {
'url': url,
'headers': headers,
'title': title or 'xstream-video',
'createdAt': time.time(),
}
_cleanup_streams()
return token
def _cleanup_streams():
cutoff = time.time() - _STREAM_TTL
for token, data in list(_STREAMS.items()):
if data.get('createdAt', 0) < cutoff:
del _STREAMS[token]
def _safe_filename(title, content_type=''):
name = ''.join(c if c.isalnum() or c in (' ', '.', '-', '_') else '_' for c in title).strip()
if not name:
name = 'xstream-video'
if '.' not in os.path.basename(name):
ext = mimetypes.guess_extension(content_type.split(';', 1)[0].strip()) or '.mp4'
name += ext
return name
def _params_for_result(result):
params = copy.deepcopy(result.get('params') or {})
params['site'] = result.get('siteId', '')
params['function'] = result.get('function', '')
params['title'] = result.get('title', '')
if result.get('thumbnail') and 'thumb' not in params:
params['thumb'] = result['thumbnail']
if result.get('title') and 'MovieTitle' not in params:
params['MovieTitle'] = result['title']
return params
def _hoster_name(hoster):
return hoster.get('displayedName') or hoster.get('name') or hoster.get('host') or hoster.get('link', 'Stream')
def _resolve_stream_url(stream):
stream_url = stream.get('streamUrl') or stream.get('link')
if not stream_url:
return ''
if stream.get('resolved'):
return stream_url
try:
import resolveurl as resolver
return resolver.resolve(stream_url)
except Exception as exc:
logger.warning('Resolver failed, returning original stream URL: %s', exc)
return stream_url
def _extract_playback(result, hoster_index=0, part_index=0):
site_id = result.get('siteId')
function_name = result.get('function')
if not site_id or site_id not in _SITE_MODULES:
raise ValueError('Unknown site')
if not function_name:
raise ValueError('Result has no playback function')
mod = _SITE_MODULES[site_id]
if not hasattr(mod, function_name):
raise ValueError('Playback function not found')
params = _params_for_result(result)
with _plugin_argv(params):
site_result = getattr(mod, function_name)()
if not site_result:
raise ValueError('No hoster or stream found')
if not isinstance(site_result, list):
site_result = [site_result]
hosters = []
selected_hoster = None
if site_result and isinstance(site_result[-1], str):
resolver_function = site_result[-1]
hosters = site_result[:-1]
if not hosters:
raise ValueError('No hoster found')
selected_hoster = hosters[max(0, min(int(hoster_index), len(hosters) - 1))]
if not hasattr(mod, resolver_function):
raise ValueError('Hoster resolver function not found')
stream_result = getattr(mod, resolver_function)(selected_hoster.get('link'))
if not stream_result:
raise ValueError('Selected hoster returned no stream')
if not isinstance(stream_result, list):
stream_result = [stream_result]
else:
stream_result = site_result
streams = [item for item in stream_result if isinstance(item, dict)]
if not streams:
raise ValueError('No stream found')
stream = streams[max(0, min(int(part_index), len(streams) - 1))]
stream_url = _resolve_stream_url(stream)
if not stream_url:
raise ValueError('Stream could not be resolved')
token = _register_stream(stream_url, result.get('title'))
return {
'title': result.get('title') or stream.get('title') or 'Stream',
'streamToken': token,
'streamUrl': '/api/stream/%s' % token,
'downloadUrl': '/api/stream/%s?download=1' % token,
'hosters': [
{'index': idx, 'name': _hoster_name(hoster)}
for idx, hoster in enumerate(hosters)
],
'selectedHoster': _hoster_name(selected_hoster) if selected_hoster else '',
'parts': [
{'index': idx, 'title': item.get('title') or ('Teil %s' % (idx + 1))}
for idx, item in enumerate(streams)
],
'selectedPart': stream.get('title') or '',
}
# ── Search helpers ────────────────────────────────────────────────────────────
def _search_site(ident, mod, query):
"""Run _search() on one site; return list of result dicts."""
try:
gui = cGui()
gui._collectMode = True
gui.searchResults = []
mod._search(gui, query)
results = []
for item in gui.searchResults:
ge = item['guiElement']
params = item.get('params')
try:
title = ge.getTitle()
except Exception:
title = ''
result = {
'title': title,
'titleSecond': ge.getTitleSecond(),
'thumbnail': ge.getThumbnail(),
'fanart': ge.getFanart(),
'description': ge.getDescription(),
'year': ge._sYear,
'mediaType': ge._mediaType,
'language': ge._sLanguage,
'quality': ge._sQuality,
'rating': ge._rating,
'site': getattr(mod, 'SITE_NAME', ident),
'siteId': ident,
'function': ge.getFunction() or '',
'isFolder': item.get('isFolder', True),
'canPlay': not item.get('isFolder', True),
}
if params:
try:
result['sUrl'] = params.getValue('sUrl') or ''
result['params'] = copy.deepcopy(params.getAllParameters())
except Exception:
result['sUrl'] = ''
result['params'] = {}
else:
result['params'] = {}
results.append(result)
return results
except Exception as exc:
logger.warning('Search error in %s: %s', ident, exc)
return []
# ── Routes ────────────────────────────────────────────────────────────────────
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/sites')
def api_sites():
sites = []
for ident, mod in _SITE_MODULES.items():
sites.append({
'id': ident,
'name': getattr(mod, 'SITE_NAME', ident),
})
return jsonify(sites)
@app.route('/api/search')
def api_search():
query = request.args.get('q', '').strip()
site_filter = request.args.get('site', '') # optional: restrict to one site
if not query:
return jsonify({'error': 'No query given', 'results': []}), 400
targets = _SITE_MODULES.items()
if site_filter:
targets = [(k, v) for k, v in targets if k == site_filter]
all_results = []
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {
pool.submit(_search_site, ident, mod, query): ident
for ident, mod in targets
}
for fut in as_completed(futures):
all_results.extend(fut.result())
# Sort: results with thumbnails first, then by title
all_results.sort(key=lambda r: (not bool(r.get('thumbnail')), r.get('title', '').lower()))
return jsonify({'query': query, 'count': len(all_results), 'results': all_results})
@app.route('/api/resolve', methods=['POST'])
def api_resolve():
payload = request.get_json(silent=True) or {}
result = payload.get('result') or {}
try:
playback = _extract_playback(
result,
hoster_index=payload.get('hosterIndex', 0),
part_index=payload.get('partIndex', 0),
)
return jsonify(playback)
except Exception as exc:
logger.warning('Resolve failed: %s', exc)
return jsonify({'error': str(exc)}), 400
@app.route('/api/stream/<token>')
def api_stream(token):
_cleanup_streams()
data = _STREAMS.get(token)
if not data:
abort(404)
headers = dict(data.get('headers') or {})
if request.headers.get('Range'):
headers['Range'] = request.headers['Range']
try:
upstream = requests.get(data['url'], headers=headers, stream=True, timeout=30)
except requests.RequestException as exc:
logger.warning('Stream request failed: %s', exc)
return jsonify({'error': 'Stream could not be opened'}), 502
excluded = {
'connection', 'content-encoding', 'keep-alive', 'proxy-authenticate',
'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade',
}
response_headers = [
(key, value)
for key, value in upstream.headers.items()
if key.lower() not in excluded
]
content_type = upstream.headers.get('Content-Type', 'application/octet-stream')
if request.args.get('download') == '1':
filename = _safe_filename(data.get('title', ''), content_type)
response_headers.append(('Content-Disposition', 'attachment; filename="%s"' % filename))
def generate():
try:
for chunk in upstream.iter_content(chunk_size=1024 * 256):
if chunk:
yield chunk
finally:
upstream.close()
return Response(
stream_with_context(generate()),
status=upstream.status_code,
headers=response_headers,
content_type=content_type,
)
# ── Startup ───────────────────────────────────────────────────────────────────
if __name__ == '__main__':
_load_sites()
logger.info('Loaded %d sites with search support.', len(_SITE_MODULES))
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)