DAP-75: Web UI für xStream-Suche erstellt
Standalone Flask-Web-UI, die die bestehenden xStream-Site-Plugins außerhalb von Kodi ausführt. Kodi-APIs werden durch leichtgewichtige Stubs ersetzt; die vorhandenen _search()-Funktionen aller 23 Sites werden über ThreadPoolExecutor parallel aufgerufen. - webui/kodi_stubs.py – Stubs für xbmc, xbmcaddon, xbmcgui, xbmcplugin, xbmcvfs, resolveurl - webui/app.py – Flask-Server mit /api/search und /api/sites - webui/templates/index.html – Dunkles Kodi-ähnliches Such-UI (Card-Grid, Filter, Detail-Overlay) - webui/requirements.txt – flask, certifi, pyaes, requests Start: cd webui && pip install -r requirements.txt && python app.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1 +1,5 @@
|
||||
.paperclip/
|
||||
webui/cache/*
|
||||
!webui/cache/.gitkeep
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
157
webui/app.py
Normal file
157
webui/app.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
xStream Web UI – standalone Flask application.
|
||||
Runs the existing xStream site-plugin scrapers outside of Kodi.
|
||||
|
||||
Usage:
|
||||
pip install flask certifi pyaes
|
||||
python app.py
|
||||
open http://localhost:5000
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import importlib.util
|
||||
import copy
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# ── 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 # 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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── 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,
|
||||
'isFolder': item.get('isFolder', True),
|
||||
}
|
||||
if params:
|
||||
try:
|
||||
result['sUrl'] = params.getValue('sUrl') or ''
|
||||
except Exception:
|
||||
result['sUrl'] = ''
|
||||
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})
|
||||
|
||||
|
||||
# ── 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)
|
||||
0
webui/cache/.gitkeep
vendored
Normal file
0
webui/cache/.gitkeep
vendored
Normal file
345
webui/kodi_stubs.py
Normal file
345
webui/kodi_stubs.py
Normal file
@@ -0,0 +1,345 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Kodi API stubs for standalone xStream web UI.
|
||||
Call install() before importing any plugin code.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import types
|
||||
import tempfile
|
||||
|
||||
_log = logging.getLogger('kodi')
|
||||
_CACHE_DIR = os.path.join(os.path.dirname(__file__), 'cache')
|
||||
os.makedirs(_CACHE_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# ── xbmc ─────────────────────────────────────────────────────────────────────
|
||||
LOGDEBUG = 0
|
||||
LOGINFO = 1
|
||||
LOGWARNING = 2
|
||||
LOGERROR = 4
|
||||
LOGFATAL = 5
|
||||
LOGNONE = 6
|
||||
|
||||
|
||||
def _make_xbmc():
|
||||
mod = types.ModuleType('xbmc')
|
||||
mod.LOGDEBUG = LOGDEBUG
|
||||
mod.LOGINFO = LOGINFO
|
||||
mod.LOGWARNING = LOGWARNING
|
||||
mod.LOGERROR = LOGERROR
|
||||
mod.LOGFATAL = LOGFATAL
|
||||
mod.LOGNONE = LOGNONE
|
||||
|
||||
def log(msg, level=LOGDEBUG):
|
||||
if level >= LOGWARNING:
|
||||
_log.warning('[xbmc] %s', msg)
|
||||
else:
|
||||
_log.debug('[xbmc] %s', msg)
|
||||
mod.log = log
|
||||
|
||||
def getCondVisibility(cond):
|
||||
if 'linux' in cond.lower() and 'raspberrypi' not in cond.lower():
|
||||
return True
|
||||
return False
|
||||
mod.getCondVisibility = getCondVisibility
|
||||
|
||||
def sleep(ms):
|
||||
import time
|
||||
time.sleep(ms / 1000.0)
|
||||
mod.sleep = sleep
|
||||
|
||||
def executebuiltin(s):
|
||||
pass
|
||||
mod.executebuiltin = executebuiltin
|
||||
|
||||
def translatePath(path):
|
||||
path = path.replace('special://home/', os.path.expanduser('~') + '/')
|
||||
path = path.replace('special://profile/', _CACHE_DIR + '/')
|
||||
path = path.replace('special://temp/', tempfile.gettempdir() + '/')
|
||||
return path
|
||||
mod.translatePath = translatePath
|
||||
|
||||
class Monitor:
|
||||
def abortRequested(self):
|
||||
return False
|
||||
def waitForAbort(self, timeout=0):
|
||||
return False
|
||||
mod.Monitor = Monitor
|
||||
return mod
|
||||
|
||||
|
||||
# ── xbmcaddon ────────────────────────────────────────────────────────────────
|
||||
_ADDON_SETTINGS = {
|
||||
'TMDBMETA': 'false',
|
||||
'replacefanart': 'false',
|
||||
'metaOverwrite': 'false',
|
||||
'bypassDNSlock': 'false',
|
||||
'volatileHtmlCache': 'false',
|
||||
'hosterSelect': 'Auto',
|
||||
'blockedHoster': '',
|
||||
'2captcha.pass': '',
|
||||
}
|
||||
|
||||
_PLUGIN_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), '..', 'plugin.video.xstream')
|
||||
)
|
||||
|
||||
|
||||
def _make_xbmcaddon():
|
||||
mod = types.ModuleType('xbmcaddon')
|
||||
|
||||
class Addon:
|
||||
def __init__(self, addon_id='plugin.video.xstream'):
|
||||
self._id = addon_id
|
||||
|
||||
def getSetting(self, key):
|
||||
return _ADDON_SETTINGS.get(key, '')
|
||||
|
||||
def setSetting(self, key, value):
|
||||
_ADDON_SETTINGS[key] = str(value)
|
||||
|
||||
def getAddonInfo(self, key):
|
||||
infos = {
|
||||
'id': 'plugin.video.xstream',
|
||||
'version': '2026.02.11',
|
||||
'name': 'xStream',
|
||||
'path': _PLUGIN_DIR,
|
||||
'profile': _CACHE_DIR,
|
||||
'icon': '',
|
||||
'fanart': '',
|
||||
}
|
||||
return infos.get(key, '')
|
||||
|
||||
def getLocalizedString(self, code):
|
||||
_strings = {
|
||||
30166: 'xStream',
|
||||
30265: 'Gesperrt',
|
||||
30260: 'Diese Seite ist gesperrt.',
|
||||
30261: 'Bitte wende dich an deinen Provider.',
|
||||
30275: 'Changelog',
|
||||
30279: 'Nächste Seite',
|
||||
30281: 'Suche',
|
||||
30322: 'Entwickleroptionen',
|
||||
30500: 'Neu',
|
||||
30502: 'Filme',
|
||||
30505: 'Dokumentationen',
|
||||
30511: 'Serien',
|
||||
30520: 'Suche',
|
||||
}
|
||||
return _strings.get(code, str(code))
|
||||
|
||||
def openSettings(self):
|
||||
pass
|
||||
|
||||
mod.Addon = Addon
|
||||
return mod
|
||||
|
||||
|
||||
# ── xbmcgui ──────────────────────────────────────────────────────────────────
|
||||
def _make_xbmcgui():
|
||||
mod = types.ModuleType('xbmcgui')
|
||||
mod.NOTIFICATION_INFO = 'info'
|
||||
mod.NOTIFICATION_WARNING = 'warning'
|
||||
mod.NOTIFICATION_ERROR = 'error'
|
||||
|
||||
class Dialog:
|
||||
def ok(self, heading, message):
|
||||
_log.info('[dialog.ok] %s: %s', heading, message)
|
||||
def notification(self, heading, message, icon='', time=5000, sound=True):
|
||||
_log.info('[dialog.notification] %s: %s', heading, message)
|
||||
def yesno(self, heading, message):
|
||||
return False
|
||||
def select(self, heading, items):
|
||||
return -1
|
||||
def input(self, heading, defaultt=''):
|
||||
return ''
|
||||
|
||||
class Window:
|
||||
def __init__(self, win_id=0):
|
||||
pass
|
||||
def getControl(self, ctrl_id):
|
||||
return None
|
||||
|
||||
class DialogProgress:
|
||||
def create(self, heading, message=''):
|
||||
pass
|
||||
def update(self, percent, message=''):
|
||||
pass
|
||||
def close(self):
|
||||
pass
|
||||
def iscanceled(self):
|
||||
return False
|
||||
|
||||
class DialogProgressBG:
|
||||
def create(self, heading, message=''):
|
||||
pass
|
||||
def update(self, percent=0, heading='', message=''):
|
||||
pass
|
||||
def close(self):
|
||||
pass
|
||||
def isFinished(self):
|
||||
return True
|
||||
|
||||
class ListItem:
|
||||
def __init__(self, label='', label2='', path=''):
|
||||
self.label = label
|
||||
def setArt(self, art):
|
||||
pass
|
||||
def setInfo(self, type, info):
|
||||
pass
|
||||
def setProperty(self, key, value):
|
||||
pass
|
||||
def addContextMenuItems(self, items):
|
||||
pass
|
||||
def setContentLookup(self, enable):
|
||||
pass
|
||||
def setMimeType(self, mime):
|
||||
pass
|
||||
|
||||
mod.Dialog = Dialog
|
||||
mod.DialogProgress = DialogProgress
|
||||
mod.DialogProgressBG = DialogProgressBG
|
||||
mod.Window = Window
|
||||
mod.ListItem = ListItem
|
||||
return mod
|
||||
|
||||
|
||||
# ── xbmcplugin ───────────────────────────────────────────────────────────────
|
||||
def _make_xbmcplugin():
|
||||
mod = types.ModuleType('xbmcplugin')
|
||||
mod.SORT_METHOD_LABEL = 1
|
||||
mod.SORT_METHOD_TITLE = 9
|
||||
|
||||
def addDirectoryItem(handle, url, listitem, isFolder=False, totalItems=0):
|
||||
pass
|
||||
def addDirectoryItems(handle, items, totalItems=0):
|
||||
pass
|
||||
def setContent(handle, content):
|
||||
pass
|
||||
def addSortMethod(handle, sortMethod):
|
||||
pass
|
||||
def setPluginCategory(handle, category):
|
||||
pass
|
||||
def endOfDirectory(handle, succeeded=True, updateListing=False, cacheToDisc=True):
|
||||
pass
|
||||
def setResolvedUrl(handle, succeeded, listitem):
|
||||
pass
|
||||
|
||||
mod.addDirectoryItem = addDirectoryItem
|
||||
mod.addDirectoryItems = addDirectoryItems
|
||||
mod.setContent = setContent
|
||||
mod.addSortMethod = addSortMethod
|
||||
mod.setPluginCategory = setPluginCategory
|
||||
mod.endOfDirectory = endOfDirectory
|
||||
mod.setResolvedUrl = setResolvedUrl
|
||||
return mod
|
||||
|
||||
|
||||
# ── xbmcvfs ──────────────────────────────────────────────────────────────────
|
||||
def _make_xbmcvfs():
|
||||
mod = types.ModuleType('xbmcvfs')
|
||||
|
||||
def translatePath(path):
|
||||
path = path.replace('special://home/', os.path.expanduser('~') + '/')
|
||||
path = path.replace('special://profile/', _CACHE_DIR + '/')
|
||||
path = path.replace('special://temp/', tempfile.gettempdir() + '/')
|
||||
return path
|
||||
mod.translatePath = translatePath
|
||||
|
||||
def exists(path):
|
||||
return os.path.exists(path)
|
||||
def mkdirs(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return True
|
||||
def delete(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
def listdir(path):
|
||||
try:
|
||||
entries = os.listdir(path)
|
||||
dirs = [e for e in entries if os.path.isdir(os.path.join(path, e))]
|
||||
files = [e for e in entries if os.path.isfile(os.path.join(path, e))]
|
||||
return dirs, files
|
||||
except Exception:
|
||||
return [], []
|
||||
def File(path, mode='r'):
|
||||
return open(path, mode)
|
||||
|
||||
mod.exists = exists
|
||||
mod.mkdirs = mkdirs
|
||||
mod.delete = delete
|
||||
mod.listdir = listdir
|
||||
mod.File = File
|
||||
return mod
|
||||
|
||||
|
||||
# ── resolveurl (stub – no actual resolution) ─────────────────────────────────
|
||||
def _make_resolveurl():
|
||||
mod = types.ModuleType('resolveurl')
|
||||
|
||||
def relevant_resolvers(domain='', url=''):
|
||||
return []
|
||||
|
||||
class HostedMediaFile:
|
||||
def __init__(self, url='', host='', media_id=''):
|
||||
self.url = url
|
||||
def get_resolvers(self):
|
||||
return []
|
||||
def resolve(self):
|
||||
return self.url
|
||||
|
||||
def resolve(url):
|
||||
return url
|
||||
|
||||
mod.relevant_resolvers = relevant_resolvers
|
||||
mod.HostedMediaFile = HostedMediaFile
|
||||
mod.resolve = resolve
|
||||
return mod
|
||||
|
||||
|
||||
# ── pyaes (lightweight stub if not installed) ─────────────────────────────────
|
||||
def _ensure_pyaes():
|
||||
try:
|
||||
import pyaes # noqa: F401
|
||||
except ImportError:
|
||||
mod = types.ModuleType('pyaes')
|
||||
class AESModeOfOperationCBC:
|
||||
def __init__(self, key, iv=None):
|
||||
pass
|
||||
def decrypt(self, data):
|
||||
return data
|
||||
class Decrypter:
|
||||
def __init__(self, aes, padding=None):
|
||||
pass
|
||||
def feed(self, data):
|
||||
return data
|
||||
class PADDING_NONE:
|
||||
pass
|
||||
mod.AESModeOfOperationCBC = AESModeOfOperationCBC
|
||||
mod.Decrypter = Decrypter
|
||||
mod.PADDING_NONE = PADDING_NONE
|
||||
sys.modules['pyaes'] = mod
|
||||
|
||||
|
||||
def install():
|
||||
"""Install all Kodi stubs into sys.modules. Call once at startup."""
|
||||
sys.modules.setdefault('xbmc', _make_xbmc())
|
||||
sys.modules.setdefault('xbmcaddon', _make_xbmcaddon())
|
||||
sys.modules.setdefault('xbmcgui', _make_xbmcgui())
|
||||
sys.modules.setdefault('xbmcplugin', _make_xbmcplugin())
|
||||
sys.modules.setdefault('xbmcvfs', _make_xbmcvfs())
|
||||
sys.modules.setdefault('resolveurl', _make_resolveurl())
|
||||
_ensure_pyaes()
|
||||
|
||||
# Some modules import from sub-namespaces
|
||||
sys.modules.setdefault('xbmc.xbmc', sys.modules['xbmc'])
|
||||
sys.modules.setdefault('xbmcvfs', sys.modules['xbmcvfs'])
|
||||
|
||||
# Make sure sys.argv has the expected Kodi plugin format
|
||||
if len(sys.argv) < 3:
|
||||
sys.argv = ['plugin://plugin.video.xstream/', '1', '']
|
||||
4
webui/requirements.txt
Normal file
4
webui/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
flask>=3.0
|
||||
certifi>=2024.0
|
||||
pyaes>=1.6
|
||||
requests>=2.32
|
||||
538
webui/templates/index.html
Normal file
538
webui/templates/index.html
Normal file
@@ -0,0 +1,538 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>xStream Web UI</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0e0e10;
|
||||
--surface: #1a1a1e;
|
||||
--surface2: #25252b;
|
||||
--accent: #e8a000;
|
||||
--accent2: #ffb700;
|
||||
--text: #e2e2e8;
|
||||
--muted: #6b6b7a;
|
||||
--border: #2e2e38;
|
||||
--radius: 10px;
|
||||
--card-w: 180px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo span { color: var(--text); }
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
padding: 10px 16px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
.search-bar input:focus { border-color: var(--accent); }
|
||||
|
||||
.search-bar button {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
font-size: .95rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
transition: background .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-bar button:hover { background: var(--accent2); }
|
||||
|
||||
/* ── Filters ── */
|
||||
#filters {
|
||||
display: none;
|
||||
padding: 12px 24px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#filters.visible { display: flex; }
|
||||
|
||||
.chip {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: .8rem;
|
||||
padding: 4px 12px;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover, .chip.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
main { padding: 24px; }
|
||||
|
||||
/* ── Status bar ── */
|
||||
#status {
|
||||
color: var(--muted);
|
||||
font-size: .9rem;
|
||||
margin-bottom: 16px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
#status .accent { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* ── Spinner ── */
|
||||
.spinner {
|
||||
display: none;
|
||||
width: 24px; height: 24px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
.spinner.visible { display: block; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Grid ── */
|
||||
#grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--card-w), 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
transition: transform .15s, border-color .15s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
aspect-ratio: 2/3;
|
||||
background: var(--surface2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-thumb img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-thumb .no-thumb {
|
||||
width: 100%; height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
position: absolute;
|
||||
top: 6px; left: 6px;
|
||||
background: rgba(0,0,0,.75);
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
font-size: .65rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-lang {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
background: rgba(0,0,0,.75);
|
||||
border-radius: 4px;
|
||||
color: #ccc;
|
||||
font-size: .65rem;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
color: var(--muted);
|
||||
font-size: .72rem;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-site {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Detail overlay ── */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.8);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
#overlay.visible { display: flex; }
|
||||
|
||||
#overlay-box {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
max-width: 620px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#overlay-fanart {
|
||||
aspect-ratio: 16/7;
|
||||
background: var(--surface2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#overlay-fanart img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
#overlay-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#overlay-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
#overlay-meta {
|
||||
color: var(--muted);
|
||||
font-size: .8rem;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#overlay-desc {
|
||||
color: #bbb;
|
||||
font-size: .85rem;
|
||||
line-height: 1.6;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#overlay-close {
|
||||
position: absolute;
|
||||
top: 10px; right: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
#overlay-close:hover { opacity: 1; }
|
||||
|
||||
/* ── Empty state ── */
|
||||
#empty {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#empty.visible { display: block; }
|
||||
|
||||
#empty .icon { font-size: 3rem; margin-bottom: 12px; }
|
||||
#empty p { font-size: .95rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo">x<span>Stream</span></div>
|
||||
<form class="search-bar" id="search-form" onsubmit="return false">
|
||||
<input type="text" id="q" placeholder="Film oder Serie suchen…" autocomplete="off" autofocus>
|
||||
<button type="submit" onclick="doSearch()">Suchen</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div id="filters"></div>
|
||||
|
||||
<main>
|
||||
<div id="status"></div>
|
||||
<div class="spinner" id="spinner"></div>
|
||||
<div id="empty">
|
||||
<div class="icon">🎬</div>
|
||||
<p>Suche nach einem Film oder einer Serie</p>
|
||||
</div>
|
||||
<div id="grid"></div>
|
||||
</main>
|
||||
|
||||
<div id="overlay">
|
||||
<div id="overlay-box">
|
||||
<button id="overlay-close" onclick="closeOverlay()">✕</button>
|
||||
<div id="overlay-fanart"><img id="overlay-fanart-img" src="" alt=""></div>
|
||||
<div id="overlay-content">
|
||||
<div id="overlay-title"></div>
|
||||
<div id="overlay-meta"></div>
|
||||
<div id="overlay-desc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
let _allResults = [];
|
||||
let _activeFilters = { site: '', type: '' };
|
||||
|
||||
// Trigger search on Enter key
|
||||
$('q').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
||||
|
||||
// Close overlay on background click
|
||||
$('overlay').addEventListener('click', e => { if (e.target === $('overlay')) closeOverlay(); });
|
||||
|
||||
async function doSearch() {
|
||||
const q = $('q').value.trim();
|
||||
if (!q) return;
|
||||
|
||||
$('grid').innerHTML = '';
|
||||
$('filters').innerHTML = '';
|
||||
$('filters').classList.remove('visible');
|
||||
$('empty').classList.remove('visible');
|
||||
$('status').textContent = 'Suche läuft…';
|
||||
$('spinner').classList.add('visible');
|
||||
_allResults = [];
|
||||
_activeFilters = { site: '', type: '' };
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/search?q=' + encodeURIComponent(q));
|
||||
const data = await res.json();
|
||||
_allResults = data.results || [];
|
||||
$('spinner').classList.remove('visible');
|
||||
|
||||
if (!_allResults.length) {
|
||||
$('status').textContent = 'Keine Ergebnisse gefunden.';
|
||||
$('empty').classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
_buildFilters();
|
||||
_render();
|
||||
} catch (err) {
|
||||
$('spinner').classList.remove('visible');
|
||||
$('status').textContent = 'Fehler: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function _buildFilters() {
|
||||
const sites = [...new Set(_allResults.map(r => r.site))].sort();
|
||||
const types = [...new Set(_allResults.map(r => r.mediaType).filter(Boolean))].sort();
|
||||
|
||||
const wrap = $('filters');
|
||||
wrap.innerHTML = '';
|
||||
|
||||
// Site chips
|
||||
if (sites.length > 1) {
|
||||
const all = _chip('Alle Quellen', () => { _activeFilters.site = ''; _render(); });
|
||||
all.classList.add('active');
|
||||
wrap.appendChild(all);
|
||||
sites.forEach(s => {
|
||||
wrap.appendChild(_chip(s, chip => {
|
||||
wrap.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
_activeFilters.site = (s === _activeFilters.site ? '' : s);
|
||||
_render();
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// Type chips
|
||||
if (types.length > 1) {
|
||||
types.forEach(t => {
|
||||
const label = { movie: 'Filme', tvshow: 'Serien', episode: 'Episoden' }[t] || t;
|
||||
wrap.appendChild(_chip(label, chip => {
|
||||
chip.classList.toggle('active');
|
||||
_activeFilters.type = chip.classList.contains('active') ? t : '';
|
||||
_render();
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (wrap.children.length) wrap.classList.add('visible');
|
||||
}
|
||||
|
||||
function _chip(label, onClick) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chip';
|
||||
el.textContent = label;
|
||||
el.addEventListener('click', () => onClick(el));
|
||||
return el;
|
||||
}
|
||||
|
||||
function _render() {
|
||||
let items = _allResults;
|
||||
if (_activeFilters.site) items = items.filter(r => r.site === _activeFilters.site);
|
||||
if (_activeFilters.type) items = items.filter(r => r.mediaType === _activeFilters.type);
|
||||
|
||||
const shown = items.length;
|
||||
const total = _allResults.length;
|
||||
$('status').innerHTML =
|
||||
'<span class="accent">' + shown + '</span> Ergebnis' + (shown !== 1 ? 'se' : '') +
|
||||
(shown !== total ? ' von ' + total : '') + ' für „' + $('q').value.trim() + '"';
|
||||
|
||||
const grid = $('grid');
|
||||
grid.innerHTML = '';
|
||||
items.forEach(r => grid.appendChild(_card(r)));
|
||||
|
||||
if (!items.length) {
|
||||
$('empty').classList.add('visible');
|
||||
} else {
|
||||
$('empty').classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function _card(r) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card';
|
||||
el.innerHTML =
|
||||
'<div class="card-thumb">' +
|
||||
(r.thumbnail
|
||||
? '<img src="' + _esc(r.thumbnail) + '" loading="lazy" onerror="this.style.display=\'none\'">'
|
||||
: '<div class="no-thumb">🎬</div>') +
|
||||
(r.mediaType ? '<div class="card-badge">' + _mediaLabel(r.mediaType) + '</div>' : '') +
|
||||
(r.language ? '<div class="card-lang">' + _esc(r.language) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="card-body">' +
|
||||
'<div class="card-title">' + _esc(r.title || '—') + '</div>' +
|
||||
'<div class="card-meta">' +
|
||||
(r.year ? '<span>' + _esc(r.year) + '</span>' : '') +
|
||||
(r.quality ? '<span>' + _esc(r.quality) + '</span>' : '') +
|
||||
'<span class="card-site">' + _esc(r.site) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
el.addEventListener('click', () => openOverlay(r));
|
||||
return el;
|
||||
}
|
||||
|
||||
function _mediaLabel(t) {
|
||||
return { movie: 'Film', tvshow: 'Serie', episode: 'Episode', season: 'Staffel' }[t] || t;
|
||||
}
|
||||
|
||||
function openOverlay(r) {
|
||||
$('overlay-title').textContent = r.title || '—';
|
||||
$('overlay-desc').textContent = r.description || '';
|
||||
const fi = $('overlay-fanart-img');
|
||||
const src = r.fanart || r.thumbnail || '';
|
||||
if (src) { fi.src = src; fi.style.display = ''; }
|
||||
else fi.style.display = 'none';
|
||||
|
||||
const parts = [];
|
||||
if (r.year) parts.push(r.year);
|
||||
if (r.mediaType) parts.push(_mediaLabel(r.mediaType));
|
||||
if (r.language) parts.push(r.language);
|
||||
if (r.quality) parts.push(r.quality);
|
||||
parts.push(r.site);
|
||||
$('overlay-meta').textContent = parts.join(' · ');
|
||||
|
||||
$('overlay').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeOverlay() {
|
||||
$('overlay').classList.remove('visible');
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g,'&')
|
||||
.replace(/</g,'<')
|
||||
.replace(/>/g,'>')
|
||||
.replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Show empty state on load
|
||||
$('empty').classList.add('visible');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user