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:
11
README.md
11
README.md
@@ -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.
|
||||
|
||||
226
webui/app.py
226
webui/app.py
@@ -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()
|
||||
|
||||
@@ -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,'&')
|
||||
|
||||
Reference in New Issue
Block a user