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>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
# -*- 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)
|