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:
DasPoschi
2026-08-18 10:54:08 +02:00
parent 2cc9cafb09
commit ed1ce03b26
6 changed files with 1048 additions and 0 deletions

4
.gitignore vendored
View File

@@ -1 +1,5 @@
.paperclip/
webui/cache/*
!webui/cache/.gitkeep
__pycache__/
*.pyc

157
webui/app.py Normal file
View 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
View File

345
webui/kodi_stubs.py Normal file
View 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
View File

@@ -0,0 +1,4 @@
flask>=3.0
certifi>=2024.0
pyaes>=1.6
requests>=2.32

538
webui/templates/index.html Normal file
View 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,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
// Show empty state on load
$('empty').classList.add('visible');
</script>
</body>
</html>