diff --git a/README.md b/README.md index ee75a24..acf01fc 100644 --- a/README.md +++ b/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. diff --git a/webui/app.py b/webui/app.py index 7f84002..bd74196 100644 --- a/webui/app.py +++ b/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/') +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() diff --git a/webui/templates/index.html b/webui/templates/index.html index f934d0e..f364624 100644 --- a/webui/templates/index.html +++ b/webui/templates/index.html @@ -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; + } + } @@ -358,6 +443,12 @@
+
+ + Download +
+ +
@@ -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 ? '' + _esc(r.quality) + '' : '') + '' + _esc(r.site) + '' + '' + + '
' + + '' + + '
' + ''; 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,'&')