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>
This commit is contained in:
Paperclip CTO
2026-08-21 17:32:43 +02:00
parent c3a259f037
commit 81450723f3
3 changed files with 406 additions and 2 deletions

View File

@@ -6,5 +6,16 @@ Standalone-Migration des Xstream-Kodi-Plugins aus dem Kino-Projekt (Nextcloud).
- plugin.video.xstream — Kodi Video-Addon
- script.module.xstreamscraper — zugehoeriges Scraper-Modul
- webui — Flask-Weboberflaeche fuer Suche, Stream-Wiedergabe und Download
Quelle: Nextcloud Botomir/Projekte/Angefangen/Kino-Projekt/. Migration am 2026-08-17 durchgefuehrt.
## WebUI starten
```bash
cd webui
pip install -r requirements.txt
python app.py
```
Danach ist die Oberflaeche unter http://localhost:5000 erreichbar. Suchtreffer, die direkt auf Streams fuehren, zeigen Aktionen zum Wiedergeben im Browser und zum Download.

View File

@@ -4,7 +4,7 @@ xStream Web UI standalone Flask application.
Runs the existing xStream site-plugin scrapers outside of Kodi.
Usage:
pip install flask certifi pyaes
pip install -r requirements.txt
python app.py
open http://localhost:5000
"""
@@ -14,7 +14,14 @@ 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__))
@@ -33,7 +40,7 @@ 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 # noqa: E402
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')
@@ -42,6 +49,8 @@ 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."""
@@ -63,6 +72,150 @@ def _load_sites():
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."""
@@ -92,13 +245,19 @@ def _search_site(ident, mod, query):
'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:
@@ -150,6 +309,69 @@ def api_search():
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()

View File

@@ -13,6 +13,7 @@
--surface2: #25252b;
--accent: #e8a000;
--accent2: #ffb700;
--danger: #e45858;
--text: #e2e2e8;
--muted: #6b6b7a;
--border: #2e2e38;
@@ -237,6 +238,51 @@
color: var(--accent);
}
.card-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.action-btn {
align-items: center;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
cursor: pointer;
display: inline-flex;
font-size: .78rem;
font-weight: 700;
justify-content: center;
min-height: 34px;
padding: 8px 10px;
text-decoration: none;
transition: background .15s, border-color .15s, color .15s;
width: 100%;
}
.action-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent2);
}
.action-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.action-btn.primary:hover:not(:disabled) {
background: var(--accent2);
color: #000;
}
.action-btn:disabled {
cursor: default;
opacity: .45;
}
/* ── Detail overlay ── */
#overlay {
display: none;
@@ -300,6 +346,30 @@
overflow-y: auto;
}
#overlay-actions {
display: none;
gap: 10px;
margin-top: 16px;
}
#overlay-actions.visible { display: flex; }
#overlay-player {
background: #000;
display: none;
margin-top: 16px;
width: 100%;
}
#overlay-player.visible { display: block; }
#overlay-action-status {
color: var(--muted);
font-size: .78rem;
margin-top: 10px;
min-height: 1.1em;
}
#overlay-close {
position: absolute;
top: 10px; right: 14px;
@@ -326,6 +396,21 @@
#empty .icon { font-size: 3rem; margin-bottom: 12px; }
#empty p { font-size: .95rem; }
@media (max-width: 640px) {
header {
align-items: stretch;
flex-direction: column;
}
.search-bar {
max-width: none;
}
#overlay-actions {
flex-direction: column;
}
}
</style>
</head>
<body>
@@ -358,6 +443,12 @@
<div id="overlay-title"></div>
<div id="overlay-meta"></div>
<div id="overlay-desc"></div>
<div id="overlay-actions">
<button class="action-btn primary" id="overlay-play">Wiedergeben</button>
<a class="action-btn" id="overlay-download" href="#" download>Download</a>
</div>
<video id="overlay-player" controls playsinline></video>
<div id="overlay-action-status"></div>
</div>
</div>
</div>
@@ -366,6 +457,8 @@
const $ = id => document.getElementById(id);
let _allResults = [];
let _activeFilters = { site: '', type: '' };
let _activeResult = null;
let _activePlayback = null;
// Trigger search on Enter key
$('q').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
@@ -491,8 +584,16 @@
(r.quality ? '<span>' + _esc(r.quality) + '</span>' : '') +
'<span class="card-site">' + _esc(r.site) + '</span>' +
'</div>' +
'<div class="card-actions">' +
'<button class="action-btn primary" type="button" ' + (r.canPlay ? '' : 'disabled') + '>Wiedergeben</button>' +
'</div>' +
'</div>';
el.addEventListener('click', () => openOverlay(r));
el.querySelector('button').addEventListener('click', e => {
e.stopPropagation();
openOverlay(r);
playActive();
});
return el;
}
@@ -501,8 +602,16 @@
}
function openOverlay(r) {
_activeResult = r;
_activePlayback = null;
$('overlay-title').textContent = r.title || '—';
$('overlay-desc').textContent = r.description || '';
$('overlay-action-status').textContent = '';
$('overlay-player').pause();
$('overlay-player').removeAttribute('src');
$('overlay-player').classList.remove('visible');
$('overlay-download').removeAttribute('href');
$('overlay-download').setAttribute('aria-disabled', 'true');
const fi = $('overlay-fanart-img');
const src = r.fanart || r.thumbnail || '';
if (src) { fi.src = src; fi.style.display = ''; }
@@ -516,13 +625,75 @@
parts.push(r.site);
$('overlay-meta').textContent = parts.join(' · ');
if (r.canPlay) {
$('overlay-actions').classList.add('visible');
$('overlay-play').disabled = false;
$('overlay-download').classList.remove('disabled');
$('overlay-download').onclick = e => {
if (!_activePlayback) {
e.preventDefault();
resolveActive(false).then(playback => {
if (playback) window.location.href = playback.downloadUrl;
});
}
};
} else {
$('overlay-actions').classList.remove('visible');
}
$('overlay').classList.add('visible');
}
function closeOverlay() {
$('overlay-player').pause();
$('overlay').classList.remove('visible');
}
$('overlay-play').addEventListener('click', () => playActive());
async function playActive() {
const playback = await resolveActive(true);
if (!playback) return;
const player = $('overlay-player');
player.src = playback.streamUrl;
player.classList.add('visible');
$('overlay-action-status').textContent = playback.selectedHoster
? 'Quelle: ' + playback.selectedHoster
: '';
try {
await player.play();
} catch (err) {
$('overlay-action-status').textContent = err.message;
}
}
async function resolveActive(forPlayback) {
if (!_activeResult) return null;
if (_activePlayback) return _activePlayback;
$('overlay-play').disabled = true;
$('overlay-action-status').textContent = 'Stream wird vorbereitet…';
try {
const res = await fetch('/api/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result: _activeResult })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Stream konnte nicht vorbereitet werden.');
_activePlayback = data;
$('overlay-download').href = data.downloadUrl;
$('overlay-download').removeAttribute('aria-disabled');
$('overlay-action-status').textContent = forPlayback ? '' : 'Download bereit.';
return data;
} catch (err) {
$('overlay-action-status').textContent = err.message;
return null;
} finally {
$('overlay-play').disabled = false;
}
}
function _esc(s) {
return String(s)
.replace(/&/g,'&amp;')