|
|
|
|
@@ -57,7 +57,6 @@ else: # pragma: no cover
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SETTING_BASE_URL = "serienstream_base_url"
|
|
|
|
|
SETTING_CATALOG_SEARCH = "serienstream_catalog_search"
|
|
|
|
|
DEFAULT_BASE_URL = "https://s.to"
|
|
|
|
|
DEFAULT_PREFERRED_HOSTERS = ["voe"]
|
|
|
|
|
DEFAULT_TIMEOUT = 20
|
|
|
|
|
@@ -80,10 +79,7 @@ HEADERS = {
|
|
|
|
|
SESSION_CACHE_TTL_SECONDS = 300
|
|
|
|
|
SESSION_CACHE_PREFIX = "viewit.serienstream"
|
|
|
|
|
SESSION_CACHE_MAX_TITLE_URLS = 800
|
|
|
|
|
CATALOG_SEARCH_TTL_SECONDS = 600
|
|
|
|
|
CATALOG_SEARCH_CACHE_KEY = "catalog_index"
|
|
|
|
|
GENRE_LIST_PAGE_SIZE = 20
|
|
|
|
|
_CATALOG_INDEX_MEMORY: tuple[float, list["SeriesResult"]] = (0.0, [])
|
|
|
|
|
ProgressCallback = Optional[Callable[[str, int | None], Any]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -575,8 +571,8 @@ def _search_series_server(query: str) -> list[SeriesResult]:
|
|
|
|
|
if not query:
|
|
|
|
|
return []
|
|
|
|
|
base = _get_base_url()
|
|
|
|
|
search_url = f"{base}/search?q={quote(query)}"
|
|
|
|
|
alt_url = f"{base}/suche?q={quote(query)}"
|
|
|
|
|
search_url = f"{base}/suche?term={quote(query)}"
|
|
|
|
|
alt_url = f"{base}/search?term={quote(query)}"
|
|
|
|
|
for url in (search_url, alt_url):
|
|
|
|
|
try:
|
|
|
|
|
body = _get_html_simple(url)
|
|
|
|
|
@@ -606,158 +602,30 @@ def _search_series_server(query: str) -> list[SeriesResult]:
|
|
|
|
|
continue
|
|
|
|
|
seen_urls.add(url_abs)
|
|
|
|
|
results.append(SeriesResult(title=title, description="", url=url_abs))
|
|
|
|
|
filtered = [r for r in results if _matches_query(query, title=r.title)]
|
|
|
|
|
if filtered:
|
|
|
|
|
return filtered
|
|
|
|
|
if results:
|
|
|
|
|
return results
|
|
|
|
|
api_results = _search_series_api(query)
|
|
|
|
|
if api_results:
|
|
|
|
|
return api_results
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_catalog_index_from_html(body: str, *, progress_callback: ProgressCallback = None) -> list[SeriesResult]:
|
|
|
|
|
items: list[SeriesResult] = []
|
|
|
|
|
if not body:
|
|
|
|
|
return items
|
|
|
|
|
seen_urls: set[str] = set()
|
|
|
|
|
item_re = re.compile(
|
|
|
|
|
r"<li[^>]*class=[\"'][^\"']*series-item[^\"']*[\"'][^>]*>(.*?)</li>",
|
|
|
|
|
re.IGNORECASE | re.DOTALL,
|
|
|
|
|
)
|
|
|
|
|
anchor_re = re.compile(r"<a[^>]+href=[\"']([^\"']+)[\"'][^>]*>(.*?)</a>", re.IGNORECASE | re.DOTALL)
|
|
|
|
|
data_search_re = re.compile(r"data-search=[\"']([^\"']*)[\"']", re.IGNORECASE)
|
|
|
|
|
for idx, match in enumerate(item_re.finditer(body), start=1):
|
|
|
|
|
if idx == 1 or idx % 200 == 0:
|
|
|
|
|
_emit_progress(progress_callback, f"Katalog parsen {idx}", 62)
|
|
|
|
|
block = match.group(0)
|
|
|
|
|
inner = match.group(1) or ""
|
|
|
|
|
anchor_match = anchor_re.search(inner)
|
|
|
|
|
if not anchor_match:
|
|
|
|
|
continue
|
|
|
|
|
href = (anchor_match.group(1) or "").strip()
|
|
|
|
|
url = _absolute_url(href)
|
|
|
|
|
if not url or "/serie/" not in url or "/staffel-" in url or "/episode-" in url:
|
|
|
|
|
continue
|
|
|
|
|
if url in seen_urls:
|
|
|
|
|
continue
|
|
|
|
|
seen_urls.add(url)
|
|
|
|
|
title_raw = anchor_match.group(2) or ""
|
|
|
|
|
title = unescape(re.sub(r"\s+", " ", _strip_tags(title_raw))).strip()
|
|
|
|
|
if not title:
|
|
|
|
|
continue
|
|
|
|
|
search_match = data_search_re.search(block)
|
|
|
|
|
description = (search_match.group(1) or "").strip() if search_match else ""
|
|
|
|
|
items.append(SeriesResult(title=title, description=description, url=url))
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _catalog_index_from_soup(soup: BeautifulSoupT) -> list[SeriesResult]:
|
|
|
|
|
items: list[SeriesResult] = []
|
|
|
|
|
if not soup:
|
|
|
|
|
return items
|
|
|
|
|
seen_urls: set[str] = set()
|
|
|
|
|
for item in soup.select("li.series-item"):
|
|
|
|
|
anchor = item.find("a", href=True)
|
|
|
|
|
if not anchor:
|
|
|
|
|
continue
|
|
|
|
|
href = (anchor.get("href") or "").strip()
|
|
|
|
|
url = _absolute_url(href)
|
|
|
|
|
if not url or "/serie/" not in url or "/staffel-" in url or "/episode-" in url:
|
|
|
|
|
continue
|
|
|
|
|
if url in seen_urls:
|
|
|
|
|
continue
|
|
|
|
|
seen_urls.add(url)
|
|
|
|
|
title = (anchor.get_text(" ", strip=True) or "").strip()
|
|
|
|
|
if not title:
|
|
|
|
|
continue
|
|
|
|
|
description = (item.get("data-search") or "").strip()
|
|
|
|
|
items.append(SeriesResult(title=title, description=description, url=url))
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_catalog_index_from_cache() -> Optional[list[SeriesResult]]:
|
|
|
|
|
global _CATALOG_INDEX_MEMORY
|
|
|
|
|
expires_at, cached = _CATALOG_INDEX_MEMORY
|
|
|
|
|
if cached and expires_at > time.time():
|
|
|
|
|
return list(cached)
|
|
|
|
|
raw = _session_cache_get(CATALOG_SEARCH_CACHE_KEY)
|
|
|
|
|
if not isinstance(raw, list):
|
|
|
|
|
return None
|
|
|
|
|
items: list[SeriesResult] = []
|
|
|
|
|
for entry in raw:
|
|
|
|
|
if not isinstance(entry, list) or len(entry) < 2:
|
|
|
|
|
continue
|
|
|
|
|
title = str(entry[0] or "").strip()
|
|
|
|
|
url = str(entry[1] or "").strip()
|
|
|
|
|
description = str(entry[2] or "") if len(entry) > 2 else ""
|
|
|
|
|
cover = str(entry[3] or "").strip() if len(entry) > 3 else ""
|
|
|
|
|
if title and url:
|
|
|
|
|
items.append(SeriesResult(title=title, description=description, url=url, cover=cover))
|
|
|
|
|
if items:
|
|
|
|
|
_CATALOG_INDEX_MEMORY = (time.time() + CATALOG_SEARCH_TTL_SECONDS, list(items))
|
|
|
|
|
return items or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _store_catalog_index_in_cache(items: list[SeriesResult]) -> None:
|
|
|
|
|
global _CATALOG_INDEX_MEMORY
|
|
|
|
|
if not items:
|
|
|
|
|
return
|
|
|
|
|
_CATALOG_INDEX_MEMORY = (time.time() + CATALOG_SEARCH_TTL_SECONDS, list(items))
|
|
|
|
|
payload: list[list[str]] = []
|
|
|
|
|
for entry in items:
|
|
|
|
|
if not entry.title or not entry.url:
|
|
|
|
|
continue
|
|
|
|
|
payload.append([entry.title, entry.url, entry.description, entry.cover])
|
|
|
|
|
_session_cache_set(CATALOG_SEARCH_CACHE_KEY, payload, ttl_seconds=CATALOG_SEARCH_TTL_SECONDS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def search_series(query: str, *, progress_callback: ProgressCallback = None) -> list[SeriesResult]:
|
|
|
|
|
"""Sucht Serien. Katalog-Suche (vollstaendig) oder API-Suche (max 10) je nach Setting."""
|
|
|
|
|
"""Sucht Serien. Server-Suche (/suche?term=) zuerst, API als Fallback."""
|
|
|
|
|
_ensure_requests()
|
|
|
|
|
if not _normalize_search_text(query):
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
use_catalog = _get_setting_bool(SETTING_CATALOG_SEARCH, default=True)
|
|
|
|
|
|
|
|
|
|
if use_catalog:
|
|
|
|
|
_emit_progress(progress_callback, "Pruefe Such-Cache", 15)
|
|
|
|
|
cached = _load_catalog_index_from_cache()
|
|
|
|
|
if cached is not None:
|
|
|
|
|
matched_from_cache = [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)]
|
|
|
|
|
_emit_progress(progress_callback, f"Cache-Treffer: {len(cached)}", 35)
|
|
|
|
|
if matched_from_cache:
|
|
|
|
|
return matched_from_cache
|
|
|
|
|
|
|
|
|
|
_emit_progress(progress_callback, "Lade Katalogseite", 42)
|
|
|
|
|
catalog_url = f"{_get_base_url()}/serien?by=genre"
|
|
|
|
|
items: list[SeriesResult] = []
|
|
|
|
|
try:
|
|
|
|
|
soup = _get_soup_simple(catalog_url)
|
|
|
|
|
items = _catalog_index_from_soup(soup)
|
|
|
|
|
except Exception:
|
|
|
|
|
body = _get_html_simple(catalog_url)
|
|
|
|
|
items = _extract_catalog_index_from_html(body, progress_callback=progress_callback)
|
|
|
|
|
if not items:
|
|
|
|
|
_emit_progress(progress_callback, "Fallback-Parser", 58)
|
|
|
|
|
soup = BeautifulSoup(body, "html.parser")
|
|
|
|
|
items = _catalog_index_from_soup(soup)
|
|
|
|
|
if items:
|
|
|
|
|
_store_catalog_index_in_cache(items)
|
|
|
|
|
_emit_progress(progress_callback, f"Filtere Treffer ({len(items)})", 70)
|
|
|
|
|
return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)]
|
|
|
|
|
|
|
|
|
|
# API-Suche (primaer wenn Katalog deaktiviert, Fallback wenn Katalog leer)
|
|
|
|
|
_emit_progress(progress_callback, "API-Suche", 60)
|
|
|
|
|
api_results = _search_series_api(query)
|
|
|
|
|
if api_results:
|
|
|
|
|
_emit_progress(progress_callback, f"API-Treffer: {len(api_results)}", 80)
|
|
|
|
|
return api_results
|
|
|
|
|
|
|
|
|
|
_emit_progress(progress_callback, "Server-Suche", 85)
|
|
|
|
|
# 1. Server-Suche (schnell, vollstaendig, direkte HTML-Suche)
|
|
|
|
|
_emit_progress(progress_callback, "Suche", 20)
|
|
|
|
|
server_results = _search_series_server(query)
|
|
|
|
|
if server_results:
|
|
|
|
|
_emit_progress(progress_callback, f"Server-Treffer: {len(server_results)}", 95)
|
|
|
|
|
return [entry for entry in server_results if entry.title and _matches_query(query, title=entry.title)]
|
|
|
|
|
return []
|
|
|
|
|
return server_results
|
|
|
|
|
|
|
|
|
|
# 2. API-Suche (Fallback, max 10 Ergebnisse)
|
|
|
|
|
_emit_progress(progress_callback, "API-Suche", 60)
|
|
|
|
|
return _search_series_api(query)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_series_catalog(soup: BeautifulSoupT) -> dict[str, list[SeriesResult]]:
|
|
|
|
|
@@ -1252,7 +1120,7 @@ class SerienstreamPlugin(BasisPlugin):
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
url = str(item.get("url") or "").strip()
|
|
|
|
|
if number <= 0 or not url:
|
|
|
|
|
if number < 0 or not url:
|
|
|
|
|
continue
|
|
|
|
|
seasons.append(SeasonInfo(number=number, url=url, episodes=[]))
|
|
|
|
|
if not seasons:
|
|
|
|
|
@@ -1794,6 +1662,8 @@ class SerienstreamPlugin(BasisPlugin):
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _season_label(number: int) -> str:
|
|
|
|
|
if number == 0:
|
|
|
|
|
return "Filme"
|
|
|
|
|
return f"Staffel {number}"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@@ -1808,6 +1678,8 @@ class SerienstreamPlugin(BasisPlugin):
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _parse_season_number(label: str) -> int | None:
|
|
|
|
|
if (label or "").strip().casefold() == "filme":
|
|
|
|
|
return 0
|
|
|
|
|
digits = "".join(ch for ch in label if ch.isdigit())
|
|
|
|
|
if not digits:
|
|
|
|
|
return None
|
|
|
|
|
|