From 73f07d20b44d3307bbca85426dae1df659be53d7 Mon Sep 17 00:00:00 2001 From: "itdrui.de" Date: Wed, 25 Feb 2026 16:35:16 +0100 Subject: [PATCH] dev: bump to 0.1.66 and harden resolveurl + serienstream --- .vscode/settings.json | 2 +- CHANGELOG-DEV.md | 8 ++ README.md | 4 + addon/addon.xml | 3 +- addon/default.py | 135 ++++++++++++++------- addon/genre_utils.py | 29 +++++ addon/plugin_helpers.py | 29 +++++ addon/plugins/_template_plugin.py | 96 ++++++++++++--- addon/plugins/aniworld_plugin.py | 35 +----- addon/plugins/dokustreams_plugin.py | 12 +- addon/plugins/einschalten_plugin.py | 12 +- addon/plugins/filmpalast_plugin.py | 12 +- addon/plugins/serienstream_plugin.py | 163 ++++++++++++-------------- addon/plugins/topstreamfilm_plugin.py | 19 +-- addon/resolveurl_backend.py | 62 +++++++++- addon/search_utils.py | 29 +++++ docs/ARCHITECTURE.md | 65 ++++++++++ docs/PLUGIN_DEVELOPMENT.md | 24 +++- pyproject.toml | 13 ++ requirements-dev.txt | 2 + 20 files changed, 522 insertions(+), 232 deletions(-) create mode 100644 addon/genre_utils.py create mode 100644 addon/search_utils.py create mode 100644 docs/ARCHITECTURE.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b38853..a3a1838 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,4 +4,4 @@ ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true -} \ No newline at end of file +} diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 8601d59..3ad68d3 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,5 +1,13 @@ # Changelog (Dev) +## 0.1.66-dev - 2026-02-25 + +- Serienstream HTTP-Fetches robuster gemacht: Retry bei kurzzeitigen Verbindungsabbruechen inkl. Session-Reset. +- ResolveURL-Import im Kodi-Addon gehaertet: Fallback ueber Addon-`lib`-Pfade (`resolveurl`, `kodi-six`, `six`). +- Resolver-Debug erweitert: Input/Output im Kodi-Log fuer jeden ResolveURL-Aufruf sichtbar. +- Playback-Guard fuer unaufgeloeste Hoster-Links (z. B. `voe.sx/e/...`) bleibt aktiv und liefert klare Fehlermeldungen. +- `script.module.resolveurl` als Addon-Abhaengigkeit in `addon.xml` eingetragen. + ## 0.1.65-dev - 2026-02-24 - Resolver-Diagnosepfad wieder vereinfacht (Fallback/Embed-Block aus dem Router entfernt). diff --git a/README.md b/README.md index 6220385..25690bc 100644 --- a/README.md +++ b/README.md @@ -35,5 +35,9 @@ Es durchsucht Provider und startet Streams. - Tests starten: `./.venv/bin/pytest` - XML Report: `./.venv/bin/pytest --cov-report=xml` +### Linting & Formatierung +- Ruff Linting: `./.venv/bin/ruff check addon docs` +- Black Format Check: `./.venv/bin/black --check addon docs` + ## Dokumentation Siehe `docs/`. diff --git a/addon/addon.xml b/addon/addon.xml index 8fd2d7f..ec1f667 100644 --- a/addon/addon.xml +++ b/addon/addon.xml @@ -1,9 +1,10 @@ - + + video diff --git a/addon/default.py b/addon/default.py index 18fa644..b38fc64 100644 --- a/addon/default.py +++ b/addon/default.py @@ -109,7 +109,7 @@ except ImportError: # pragma: no cover - allow importing outside Kodi (e.g. lin from plugin_interface import BasisPlugin from http_session_pool import close_all_sessions -from plugin_helpers import normalize_resolved_stream_url +from plugin_helpers import normalize_resolved_stream_url, show_error, show_notification from metadata_utils import ( collect_plugin_metadata as _collect_plugin_metadata, merge_metadata as _merge_metadata, @@ -3510,39 +3510,31 @@ def _apply_update_channel(*, silent: bool = False) -> bool: if not silent: if not applied: warning_icon = getattr(xbmcgui, "NOTIFICATION_WARNING", xbmcgui.NOTIFICATION_INFO) - xbmcgui.Dialog().notification( + show_notification( "Updates", "Kanal gespeichert, aber repository.viewit nicht gefunden.", - warning_icon, - 5000, + icon=warning_icon, + milliseconds=5000, ) elif target_version == "-": - xbmcgui.Dialog().notification( - "Updates", - "Kanal angewendet, aber keine Version im Kanal gefunden.", - xbmcgui.NOTIFICATION_ERROR, - 5000, - ) + show_error("Updates", "Kanal angewendet, aber keine Version im Kanal gefunden.", milliseconds=5000) elif not install_result: - xbmcgui.Dialog().notification( + show_error( "Updates", f"Kanal angewendet, Installation von {target_version} fehlgeschlagen.", - xbmcgui.NOTIFICATION_ERROR, - 5000, + milliseconds=5000, ) elif target_version == installed_version: - xbmcgui.Dialog().notification( + show_notification( "Updates", f"Kanal angewendet: {_channel_label(_selected_update_channel())} ({target_version} bereits installiert)", - xbmcgui.NOTIFICATION_INFO, - 4500, + milliseconds=4500, ) else: - xbmcgui.Dialog().notification( + show_notification( "Updates", f"Kanal angewendet: {_channel_label(_selected_update_channel())} -> {target_version} installiert", - xbmcgui.NOTIFICATION_INFO, - 5000, + milliseconds=5000, ) _sync_update_version_settings() return applied and install_result @@ -3559,14 +3551,11 @@ def _run_update_check(*, silent: bool = False) -> None: if callable(builtin): builtin("ActivateWindow(addonbrowser,addons://updates/)") if not silent: - xbmcgui.Dialog().notification("Updates", "Update-Check gestartet.", xbmcgui.NOTIFICATION_INFO, 4000) + show_notification("Updates", "Update-Check gestartet.", milliseconds=4000) except Exception as exc: _log(f"Update-Pruefung fehlgeschlagen: {exc}", xbmc.LOGWARNING) if not silent: - try: - xbmcgui.Dialog().notification("Updates", "Update-Check fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4000) - except Exception: - pass + show_error("Updates", "Update-Check fehlgeschlagen.", milliseconds=4000) def _show_version_selector() -> None: @@ -3579,7 +3568,7 @@ def _show_version_selector() -> None: versions = _filter_versions_for_channel(channel, _fetch_repo_versions(info_url)) if not versions: - xbmcgui.Dialog().notification("Updates", "Keine Versionen im Repo gefunden.", xbmcgui.NOTIFICATION_ERROR, 4000) + show_error("Updates", "Keine Versionen im Repo gefunden.", milliseconds=4000) return installed = _get_setting_string("update_installed_version").strip() or "-" @@ -3617,13 +3606,13 @@ def _show_version_selector() -> None: if not confirmed: return - xbmcgui.Dialog().notification("Updates", f"Installation gestartet: {version}", xbmcgui.NOTIFICATION_INFO, 2500) + show_notification("Updates", f"Installation gestartet: {version}", milliseconds=2500) ok = _install_addon_version(info_url, version) if ok: _sync_update_version_settings() - xbmcgui.Dialog().notification("Updates", f"Version {version} installiert.", xbmcgui.NOTIFICATION_INFO, 4000) + show_notification("Updates", f"Version {version} installiert.", milliseconds=4000) else: - xbmcgui.Dialog().notification("Updates", f"Installation von {version} fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4500) + show_error("Updates", f"Installation von {version} fehlgeschlagen.", milliseconds=4500) def _maybe_run_auto_update_check(action: str | None) -> None: @@ -3677,6 +3666,58 @@ def _is_resolveurl_missing_error(message: str) -> bool: return str(message or "").strip().casefold() == "resolveurl missing" +def _looks_like_unresolved_hoster_link(url: str) -> bool: + raw = (url or "").strip() + if not raw: + return False + media_url = raw.split("|", 1)[0].strip() + try: + parsed = urlparse(media_url) + except Exception: + return False + host = (parsed.netloc or "").casefold() + path = (parsed.path or "").casefold() + if parsed.scheme not in {"http", "https"} or not host: + return False + known_hoster_domains = ( + "voe.sx", + "supervideo.", + "doodstream.", + "vidnest.", + "vidara.", + "filemoon.", + "streamtape.", + "vidmoly.", + "veev.", + "strmup.", + ) + if not any(domain in host for domain in known_hoster_domains): + return False + return path.startswith(("/e/", "/v/", "/d/", "/embed")) + + +def _resolve_unresolved_hoster_link(url: str, *, source_url: str) -> tuple[str, str]: + candidate = (url or "").strip() + if not _looks_like_unresolved_hoster_link(candidate): + return candidate, "" + _log(f"ResolveURL dispatch: {candidate}", xbmc.LOGDEBUG) + try: + from resolveurl_backend import resolve as resolve_with_resolveurl # type: ignore + except Exception: + resolve_with_resolveurl = None + if callable(resolve_with_resolveurl): + try: + resolved = resolve_with_resolveurl(candidate) + except Exception: + resolved = None + if resolved: + _log(f"ResolveURL output: {resolved}", xbmc.LOGDEBUG) + return normalize_resolved_stream_url(resolved, source_url=source_url or candidate), "" + err = _resolveurl_last_error() + _log(f"ResolveURL output: ({err})", xbmc.LOGDEBUG) + return candidate, err + + def _play_final_link( link: str, *, @@ -3818,23 +3859,30 @@ def _play_episode( err = _resolveurl_last_error() if _is_cloudflare_challenge_error(err): _log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification( + show_notification( "Wiedergabe", "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", - xbmcgui.NOTIFICATION_INFO, - 4500, + milliseconds=4500, ) return final_link = resolved_link or link final_link = normalize_resolved_stream_url(final_link, source_url=link) + final_link, resolve_err = _resolve_unresolved_hoster_link(final_link, source_url=link) + if _looks_like_unresolved_hoster_link(final_link): + err = (resolve_err or _resolveurl_last_error()).strip() + if _is_resolveurl_missing_error(err): + show_error("Wiedergabe", "ResolveURL fehlt oder ist nicht geladen.", milliseconds=4500) + else: + show_error("Wiedergabe", "Hoster-Link konnte nicht aufgeloest werden.", milliseconds=4500) + _log(f"Hoster-Link blieb unaufgeloest: {final_link} (error={err})", xbmc.LOGWARNING) + return err = _resolveurl_last_error() if _is_cloudflare_challenge_error(err) and final_link.strip() == link.strip(): _log(f"ResolveURL Cloudflare-Challenge (unresolved): {err}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification( + show_notification( "Wiedergabe", "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", - xbmcgui.NOTIFICATION_INFO, - 4500, + milliseconds=4500, ) return finally: @@ -3933,23 +3981,30 @@ def _play_episode_url( err = _resolveurl_last_error() if _is_cloudflare_challenge_error(err): _log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification( + show_notification( "Wiedergabe", "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", - xbmcgui.NOTIFICATION_INFO, - 4500, + milliseconds=4500, ) return final_link = resolved_link or link final_link = normalize_resolved_stream_url(final_link, source_url=link) + final_link, resolve_err = _resolve_unresolved_hoster_link(final_link, source_url=link) + if _looks_like_unresolved_hoster_link(final_link): + err = (resolve_err or _resolveurl_last_error()).strip() + if _is_resolveurl_missing_error(err): + show_error("Wiedergabe", "ResolveURL fehlt oder ist nicht geladen.", milliseconds=4500) + else: + show_error("Wiedergabe", "Hoster-Link konnte nicht aufgeloest werden.", milliseconds=4500) + _log(f"Hoster-Link blieb unaufgeloest: {final_link} (error={err})", xbmc.LOGWARNING) + return err = _resolveurl_last_error() if _is_cloudflare_challenge_error(err) and final_link.strip() == link.strip(): _log(f"ResolveURL Cloudflare-Challenge (unresolved): {err}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification( + show_notification( "Wiedergabe", "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", - xbmcgui.NOTIFICATION_INFO, - 4500, + milliseconds=4500, ) return finally: diff --git a/addon/genre_utils.py b/addon/genre_utils.py new file mode 100644 index 0000000..9fc0f71 --- /dev/null +++ b/addon/genre_utils.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import re +from html import unescape + + +def normalize_genre_label(raw: str) -> str: + """Normalisiert Genre-Bezeichner aus HTML-Labels oder Datenattributen.""" + + text = unescape(re.sub(r"\s+", " ", str(raw or ""))).strip() + if not text: + return "" + key_prefix = "filter.genre_" + if text.casefold().startswith(key_prefix): + slug = text[len(key_prefix) :].strip().casefold() + slug = slug.replace("_", "-") + slug = re.sub(r"[^a-z0-9-]+", "-", slug).strip("-") + if not slug: + return "" + special = { + "doku-soap": "Doku-Soap", + "scifi": "SciFi", + "fighting-shounen": "Fighting-Shounen", + } + if slug in special: + return special[slug] + return " ".join(chunk.capitalize() for chunk in slug.split("-") if chunk) + return text + diff --git a/addon/plugin_helpers.py b/addon/plugin_helpers.py index 31c4d42..3ca5e7b 100644 --- a/addon/plugin_helpers.py +++ b/addon/plugin_helpers.py @@ -96,6 +96,35 @@ def notify_url( return +def show_notification( + heading: str, + message: str, + *, + icon: int | None = None, + milliseconds: int = 3000, +) -> None: + """Zeigt eine kurze Kodi-Notification an (falls `xbmcgui` verfuegbar ist).""" + + if xbmcgui is None: + return + try: + icon_value = icon if icon is not None else xbmcgui.NOTIFICATION_INFO + xbmcgui.Dialog().notification(str(heading or ""), str(message or ""), icon_value, int(milliseconds)) + except Exception: + return + + +def show_error(heading: str, message: str, *, milliseconds: int = 4000) -> None: + """Zeigt eine einheitliche Fehlermeldung im Kodi-UI.""" + + if xbmcgui is None: + return + try: + xbmcgui.Dialog().notification(str(heading or ""), str(message or ""), xbmcgui.NOTIFICATION_ERROR, int(milliseconds)) + except Exception: + return + + def _profile_logs_dir(addon_id: str) -> Optional[str]: if xbmcaddon is None or xbmcvfs is None: return None diff --git a/addon/plugins/_template_plugin.py b/addon/plugins/_template_plugin.py index 5096443..043cba1 100644 --- a/addon/plugins/_template_plugin.py +++ b/addon/plugins/_template_plugin.py @@ -1,9 +1,15 @@ -"""Template fuer ein neues ViewIt-Plugin (Basis: serienstream_plugin). +"""Template fuer ein neues ViewIt-Plugin. Diese Datei wird NICHT automatisch geladen (Dateiname beginnt mit `_`). -Zum Verwenden: -1) Kopiere/benenne die Datei um (ohne fuehrenden Unterstrich), z.B. `my_site_plugin.py` -2) Passe `name`, `BASE_URL` und die Implementierungen an. + +Vorgehen fuer ein neues Plugin: + +1. Datei kopieren/umbenennen (ohne fuehrenden Unterstrich), z.B. `my_site_plugin.py` +2. `name`, `ADDON_ID`, `BASE_URL` und Header anpassen +3. `search_titles`, `seasons_for`, `episodes_for` gemaess Zielseite implementieren +4. Optional weitere Methoden wie `stream_link_for`, `resolve_stream_link`, + `popular_series`, `genres`, `titles_for_genre`, `available_hosters_for` etc. + implementieren – siehe `docs/PLUGIN_DEVELOPMENT.md` und bestehende Plugins. """ from __future__ import annotations @@ -48,20 +54,33 @@ HEADERS = { "Connection": "keep-alive", } +ProgressCallback = Optional[Callable[[str, Optional[int]], Any]] + @dataclass(frozen=True) class TitleHit: - """Ein Suchtreffer mit Titel und Detail-URL.""" + """Ein einfacher Suchtreffer mit Titel und Detail-URL.""" title: str url: str class TemplatePlugin(BasisPlugin): - """Vorlage fuer eine Streamingseiten-Integration. + """Vorlage fuer eine HTML-basierte Streamingseiten-Integration. - Optional kann ein Plugin Capabilities deklarieren (z.B. `popular_series`), - damit der Router passende Menüpunkte anbieten kann. + Dieses Template zeigt nur die MINIMALE, aber reale Schnittstelle: + + Pflicht: + - `async search_titles(query, progress_callback=None) -> list[str]` + - `seasons_for(title) -> list[str]` + - `episodes_for(title, season) -> list[str]` + + Empfohlen (optional, je nach Use-Case): + - `capabilities()` mit z.B. `popular_series`, `genres`, `latest_episodes` + - `popular_series()`, `titles_for_genre()`, `titles_for_genre_page()` + - `stream_link_for(...)` und/oder `stream_link_for_url(...)` + - `resolve_stream_link(link)` fuer Hosters/Redirects + - `metadata_for(title)` fuer eigene Metadaten (siehe bestehende Plugins) """ name = "Template" @@ -71,15 +90,25 @@ class TemplatePlugin(BasisPlugin): @property def is_available(self) -> bool: + """Signalisiert dem Router, ob das Plugin nutzbar ist (z.B. Abhaengigkeiten vorhanden).""" + return REQUESTS_AVAILABLE @property def unavailable_reason(self) -> str: + """Optionaler Grund, warum `is_available` false ist (z.B. fehlende Pakete).""" + if REQUESTS_AVAILABLE: return "" return f"requests/bs4 nicht verfuegbar: {REQUESTS_IMPORT_ERROR}" def _get_session(self) -> RequestsSession: + """Gibt eine vorkonfigurierte `requests.Session` zurueck. + + In echten Plugins kann hier auch `http_session_pool.get_requests_session(...)` + genutzt werden, wenn mehrere Module sich Sessions teilen sollen. + """ + if requests is None: raise RuntimeError(self.unavailable_reason) if self._session is None: @@ -91,41 +120,72 @@ class TemplatePlugin(BasisPlugin): async def search_titles( self, query: str, - progress_callback: Optional[Callable[[str, Optional[int]], Any]] = None, + progress_callback: ProgressCallback = None, ) -> List[str]: - """TODO: Suche auf der Zielseite implementieren.""" + """Sucht Titel auf der Zielseite und liefert eine Liste an Titel-Strings. + + Best Practices: + - Nur passende Titel liefern (wortbasiert, keine Zufallstreffer). + - `progress_callback(message, percent)` sparsam nutzen, um lange Suchen anzuzeigen. + - HTTP-Requests robust kapseln (Timeouts, Fehlerbehandlung, optionales Logging). + """ + _ = (query, progress_callback) return [] def seasons_for(self, title: str) -> List[str]: - """TODO: Staffeln fuer einen Titel liefern.""" + """Liefert alle Staffeln fuer einen Titel, z.B. `['Staffel 1', 'Staffel 2']`. + + Fuer reine Film-Provider kann stattdessen z.B. `['Film']` zurueckgegeben werden + (siehe \"Film Provider Standard\" in `docs/PLUGIN_DEVELOPMENT.md`). + """ + _ = title return [] def episodes_for(self, title: str, season: str) -> List[str]: - """TODO: Episoden fuer Titel+Staffel liefern.""" + """Liefert Episoden-Labels fuer einen Titel und eine Staffel. + + Beispiele: + - `['Episode 1', 'Episode 2']` + - `['Episode 1: Pilot', 'Episode 2: Finale']` + """ + _ = (title, season) return [] def capabilities(self) -> set[str]: - """Optional: Deklariert Fähigkeiten dieses Plugins. + """Optional: Deklariert die Faehigkeiten dieses Plugins. Beispiele: - - `popular_series`: Plugin kann beliebte Serien liefern - - `genres`: Plugin unterstützt Genre-Browser + - `popular_series`: Plugin kann beliebte Titel liefern + - `genres`: Plugin unterstuetzt Genre-Browser + - `latest_episodes`: Plugin liefert eine Liste neuer Episoden """ return set() def popular_series(self) -> List[str]: - """Optional: Liste beliebter Serien (nur wenn `popular_series` gesetzt ist).""" + """Optional: Liste beliebter Titel (wenn `popular_series` in `capabilities()` gesetzt ist).""" + return [] def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]: - """Optional: Embed-/Hoster-Link fuer eine Episode.""" + """Optional: Embed-/Hoster-Link fuer eine Episode. + + Der Router ruft diese Methode nur auf, wenn sie existiert. Der Rueckgabewert + ist entweder ein finaler Stream-Link oder ein Hoster-/Embed-Link, der spaeter + ueber `resolve_stream_link` oder ResolveURL weiter aufgeloest werden kann. + """ + _ = (title, season, episode) return None def resolve_stream_link(self, link: str) -> Optional[str]: - """Optional: Redirect-/Mirror-Aufloesung.""" + """Optional: Redirect-/Mirror-Aufloesung fuer Hoster-Links. + + Falls nicht ueberschrieben, kann der Router (oder ResolveURL) den Link + direkt verwenden. Plugins koennen hier z.B. HTTP-Redirects verfolgen. + """ + return link diff --git a/addon/plugins/aniworld_plugin.py b/addon/plugins/aniworld_plugin.py index f105eb4..d887f9e 100644 --- a/addon/plugins/aniworld_plugin.py +++ b/addon/plugins/aniworld_plugin.py @@ -39,6 +39,8 @@ from plugin_interface import BasisPlugin from plugin_helpers import dump_response_html, get_setting_bool, get_setting_string, log_error, log_url, notify_url from http_session_pool import get_requests_session from regex_patterns import DIGITS, SEASON_EPISODE_TAG, SEASON_EPISODE_URL, STAFFEL_NUM_IN_URL +from search_utils import matches_query as _shared_matches_query, normalize_search_text as _shared_normalize_search_text +from genre_utils import normalize_genre_label as _normalize_genre_label if TYPE_CHECKING: # pragma: no cover from requests import Session as RequestsSession @@ -257,10 +259,7 @@ def _log_error(message: str) -> None: def _normalize_search_text(value: str) -> str: - value = (value or "").casefold() - value = re.sub(r"[^a-z0-9]+", " ", value) - value = re.sub(r"\s+", " ", value).strip() - return value + return _shared_normalize_search_text(value) def _strip_html(text: str) -> str: @@ -270,11 +269,7 @@ def _strip_html(text: str) -> str: def _matches_query(query: str, *, title: str) -> bool: - normalized_query = _normalize_search_text(query) - if not normalized_query: - return False - haystack = f" {_normalize_search_text(title)} " - return f" {normalized_query} " in haystack + return _shared_matches_query(query, title=title) def _ensure_requests() -> None: @@ -357,28 +352,6 @@ def _get_soup_simple(url: str) -> BeautifulSoupT: return BeautifulSoup(body, "html.parser") -def _normalize_genre_label(raw: str) -> str: - text = unescape(re.sub(r"\s+", " ", str(raw or ""))).strip() - if not text: - return "" - key_prefix = "filter.genre_" - if text.casefold().startswith(key_prefix): - slug = text[len(key_prefix) :].strip().casefold() - slug = slug.replace("_", "-") - slug = re.sub(r"[^a-z0-9-]+", "-", slug).strip("-") - if not slug: - return "" - special = { - "doku-soap": "Doku-Soap", - "scifi": "SciFi", - "fighting-shounen": "Fighting-Shounen", - } - if slug in special: - return special[slug] - return " ".join(chunk.capitalize() for chunk in slug.split("-") if chunk) - return text - - def _extract_genre_names_from_html(body: str) -> List[str]: names: List[str] = [] seen: set[str] = set() diff --git a/addon/plugins/dokustreams_plugin.py b/addon/plugins/dokustreams_plugin.py index 58fffc8..778dd9b 100644 --- a/addon/plugins/dokustreams_plugin.py +++ b/addon/plugins/dokustreams_plugin.py @@ -21,6 +21,7 @@ else: from plugin_interface import BasisPlugin from plugin_helpers import dump_response_html, get_setting_bool, get_setting_string, log_error, log_url, notify_url +from search_utils import matches_query as _shared_matches_query, normalize_search_text as _shared_normalize_search_text from http_session_pool import get_requests_session if TYPE_CHECKING: # pragma: no cover @@ -161,18 +162,11 @@ def _absolute_url(url: str) -> str: def _normalize_search_text(value: str) -> str: - value = (value or "").casefold() - value = re.sub(r"[^a-z0-9]+", " ", value) - value = re.sub(r"\s+", " ", value).strip() - return value + return _shared_normalize_search_text(value) def _matches_query(query: str, *, title: str) -> bool: - normalized_query = _normalize_search_text(query) - if not normalized_query: - return False - haystack = f" {_normalize_search_text(title)} " - return f" {normalized_query} " in haystack + return _shared_matches_query(query, title=title) def _log_url_event(url: str, *, kind: str = "VISIT") -> None: diff --git a/addon/plugins/einschalten_plugin.py b/addon/plugins/einschalten_plugin.py index d13e122..9e7c47d 100644 --- a/addon/plugins/einschalten_plugin.py +++ b/addon/plugins/einschalten_plugin.py @@ -31,6 +31,7 @@ except ImportError: # pragma: no cover - allow running outside Kodi from plugin_interface import BasisPlugin from plugin_helpers import dump_response_html, get_setting_bool, log_error, log_url, notify_url +from search_utils import matches_query as _shared_matches_query, normalize_search_text as _shared_normalize_search_text ADDON_ID = "plugin.video.viewit" SETTING_BASE_URL = "einschalten_base_url" @@ -97,18 +98,11 @@ class MovieDetail: def _normalize_search_text(value: str) -> str: - value = (value or "").casefold() - value = re.sub(r"[^a-z0-9]+", " ", value) - value = re.sub(r"\s+", " ", value).strip() - return value + return _shared_normalize_search_text(value) def _matches_query(query: str, *, title: str) -> bool: - normalized_query = _normalize_search_text(query) - if not normalized_query: - return False - haystack = f" {_normalize_search_text(title)} " - return f" {normalized_query} " in haystack + return _shared_matches_query(query, title=title) def _filter_movies_by_title(query: str, movies: List[MovieItem]) -> List[MovieItem]: diff --git a/addon/plugins/filmpalast_plugin.py b/addon/plugins/filmpalast_plugin.py index cbabffd..589ff68 100644 --- a/addon/plugins/filmpalast_plugin.py +++ b/addon/plugins/filmpalast_plugin.py @@ -27,6 +27,7 @@ else: from plugin_interface import BasisPlugin from plugin_helpers import dump_response_html, get_setting_bool, get_setting_string, log_error, log_url, notify_url +from search_utils import matches_query as _shared_matches_query, normalize_search_text as _shared_normalize_search_text from http_session_pool import get_requests_session if TYPE_CHECKING: # pragma: no cover @@ -106,18 +107,11 @@ def _absolute_url(url: str) -> str: def _normalize_search_text(value: str) -> str: - value = (value or "").casefold() - value = re.sub(r"[^a-z0-9]+", " ", value) - value = re.sub(r"\s+", " ", value).strip() - return value + return _shared_normalize_search_text(value) def _matches_query(query: str, *, title: str) -> bool: - normalized_query = _normalize_search_text(query) - if not normalized_query: - return False - haystack = f" {_normalize_search_text(title)} " - return f" {normalized_query} " in haystack + return _shared_matches_query(query, title=title) def _is_probably_content_url(url: str) -> bool: diff --git a/addon/plugins/serienstream_plugin.py b/addon/plugins/serienstream_plugin.py index e05e0d9..1f9fccb 100644 --- a/addon/plugins/serienstream_plugin.py +++ b/addon/plugins/serienstream_plugin.py @@ -43,8 +43,10 @@ except ImportError: # pragma: no cover - allow running outside Kodi from plugin_interface import BasisPlugin from plugin_helpers import dump_response_html, get_setting_bool, get_setting_string, log_error, log_url, notify_url -from http_session_pool import get_requests_session +from http_session_pool import close_all_sessions, get_requests_session from regex_patterns import SEASON_EPISODE_TAG, SEASON_EPISODE_URL +from search_utils import matches_query as _matches_query, normalize_search_text as _normalize_search_text +from genre_utils import normalize_genre_label as _normalize_genre_label if TYPE_CHECKING: # pragma: no cover from requests import Session as RequestsSession @@ -293,27 +295,6 @@ def _normalize_text(value: str) -> str: return value -def _normalize_search_text(value: str) -> str: - """Normalisiert Text für die Suche ohne Wortgrenzen zu "verschmelzen". - - Wichtig: Wir ersetzen Nicht-Alphanumerisches durch Leerzeichen, statt es zu entfernen. - Dadurch entstehen keine künstlichen Treffer über Wortgrenzen hinweg (z.B. "an" + "na" -> "anna"). - """ - - value = (value or "").casefold() - value = re.sub(r"[^a-z0-9]+", " ", value) - value = re.sub(r"\s+", " ", value).strip() - return value - - -def _matches_query(query: str, *, title: str) -> bool: - normalized_query = _normalize_search_text(query) - if not normalized_query: - return False - haystack = f" {_normalize_search_text(title)} " - return f" {normalized_query} " in haystack - - def _is_episode_tba(title: str, original_title: str) -> bool: combined = f"{title} {original_title}".casefold() markers = ("tba", "demnächst", "demnaechst", "coming soon", "to be announced") @@ -392,6 +373,42 @@ def _ensure_requests() -> None: raise RuntimeError("requests/bs4 sind nicht verfuegbar.") +def _is_retryable_http_error(exc: Exception) -> bool: + text = str(exc or "").casefold() + markers = ( + "connection reset by peer", + "connection aborted", + "remote end closed connection", + "temporarily unavailable", + "timed out", + "read timeout", + ) + return any(marker in text for marker in markers) + + +def _http_get(url: str, *, timeout: int, params: Optional[dict[str, str]] = None): + _ensure_requests() + last_exc: Exception | None = None + for attempt in range(2): + sess = get_requests_session("serienstream", headers=HEADERS) + try: + response = sess.get(url, params=params, headers=HEADERS, timeout=timeout) + response.raise_for_status() + return response + except Exception as exc: + last_exc = exc + if attempt >= 1 or not _is_retryable_http_error(exc): + raise + _log_error(f"GET {url} retry nach Fehler: {exc}") + try: + close_all_sessions() + except Exception: + pass + if last_exc is not None: + raise last_exc + raise RuntimeError(f"GET {url} fehlgeschlagen") + + def _looks_like_cloudflare_challenge(body: str) -> bool: lower = body.lower() markers = ( @@ -409,11 +426,13 @@ def _looks_like_cloudflare_challenge(body: str) -> bool: def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> BeautifulSoupT: _ensure_requests() _log_visit(url) - sess = session or get_requests_session("serienstream", headers=HEADERS) response = None try: - response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) - response.raise_for_status() + if session is not None: + response = session.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + else: + response = _http_get(url, timeout=DEFAULT_TIMEOUT) except Exception as exc: _log_error(f"GET {url} failed: {exc}") raise @@ -437,11 +456,9 @@ def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> Beautif def _get_html_simple(url: str) -> str: _ensure_requests() _log_visit(url) - sess = get_requests_session("serienstream", headers=HEADERS) response = None try: - response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) - response.raise_for_status() + response = _http_get(url, timeout=DEFAULT_TIMEOUT) except Exception as exc: _log_error(f"GET {url} failed: {exc}") raise @@ -468,27 +485,6 @@ def _get_soup_simple(url: str) -> BeautifulSoupT: def _extract_genre_names_from_html(body: str) -> List[str]: - def _normalize_genre_label(raw: str) -> str: - text = unescape(re.sub(r"\s+", " ", str(raw or ""))).strip() - if not text: - return "" - key_prefix = "filter.genre_" - if text.casefold().startswith(key_prefix): - slug = text[len(key_prefix) :].strip().casefold() - slug = slug.replace("_", "-") - slug = re.sub(r"[^a-z0-9-]+", "-", slug).strip("-") - if not slug: - return "" - special = { - "doku-soap": "Doku-Soap", - "scifi": "SciFi", - "fighting-shounen": "Fighting-Shounen", - } - if slug in special: - return special[slug] - return " ".join(chunk.capitalize() for chunk in slug.split("-") if chunk) - return text - names: List[str] = [] seen: set[str] = set() pattern = re.compile( @@ -577,9 +573,6 @@ def _search_series_api(query: str) -> List[SeriesResult]: def _search_series_server(query: str) -> List[SeriesResult]: if not query: return [] - api_results = _search_series_api(query) - if api_results: - return api_results base = _get_base_url() search_url = f"{base}/search?q={quote(query)}" alt_url = f"{base}/suche?q={quote(query)}" @@ -614,6 +607,9 @@ def _search_series_server(query: str) -> List[SeriesResult]: results.append(SeriesResult(title=title, description="", url=url_abs)) if results: return results + api_results = _search_series_api(query) + if api_results: + return api_results return [] @@ -718,56 +714,45 @@ def search_series(query: str, *, progress_callback: ProgressCallback = None) -> _ensure_requests() if not _normalize_search_text(query): return [] - _emit_progress(progress_callback, "Server-Suche", 15) - server_results = _search_series_server(query) - if server_results: - _emit_progress(progress_callback, f"Server-Treffer: {len(server_results)}", 35) - return [entry for entry in server_results if entry.title and _matches_query(query, title=entry.title)] - _emit_progress(progress_callback, "Pruefe Such-Cache", 42) + _emit_progress(progress_callback, "Pruefe Such-Cache", 15) cached = _load_catalog_index_from_cache() if cached is not None: - _emit_progress(progress_callback, f"Cache-Treffer: {len(cached)}", 52) - return [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)] + 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", 58) + _emit_progress(progress_callback, "Lade Katalogseite", 42) catalog_url = f"{_get_base_url()}/serien?by=genre" - 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", 70) - soup = BeautifulSoup(body, "html.parser") + items: List[SeriesResult] = [] + try: + # Bevorzugt den Soup-Helper, damit Tests HTML einfache injizieren koennen. + 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)})", 85) - return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)] + _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)] + + _emit_progress(progress_callback, "Server-Suche", 85) + 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 [] def parse_series_catalog(soup: BeautifulSoupT) -> Dict[str, List[SeriesResult]]: """Parst die Serien-Übersicht (/serien) und liefert Genre -> Serienliste.""" catalog: Dict[str, List[SeriesResult]] = {} - def _normalize_genre_label(raw: str) -> str: - text = re.sub(r"\s+", " ", str(raw or "")).strip() - if not text: - return "" - key_prefix = "filter.genre_" - if text.casefold().startswith(key_prefix): - slug = text[len(key_prefix) :].strip().casefold() - slug = slug.replace("_", "-") - slug = re.sub(r"[^a-z0-9-]+", "-", slug).strip("-") - if not slug: - return "" - special = { - "doku-soap": "Doku-Soap", - "scifi": "SciFi", - "fighting-shounen": "Fighting-Shounen", - } - if slug in special: - return special[slug] - return " ".join(chunk.capitalize() for chunk in slug.split("-") if chunk) - return text - # Neues Layout (Stand: 2026-01): Gruppen-Header + Liste. # - Header: `div.background-1 ...` mit `h3` # - Einträge: `ul.series-list` -> `li.series-item[data-search]` -> `a[href]` diff --git a/addon/plugins/topstreamfilm_plugin.py b/addon/plugins/topstreamfilm_plugin.py index 099ff87..542c432 100644 --- a/addon/plugins/topstreamfilm_plugin.py +++ b/addon/plugins/topstreamfilm_plugin.py @@ -46,6 +46,7 @@ except ImportError: # pragma: no cover - allow running outside Kodi from plugin_interface import BasisPlugin from plugin_helpers import dump_response_html, get_setting_bool, log_error, log_url, notify_url from regex_patterns import DIGITS +from search_utils import matches_query as _shared_matches_query, normalize_search_text as _shared_normalize_search_text if TYPE_CHECKING: # pragma: no cover from requests import Session as RequestsSession @@ -98,24 +99,12 @@ class SearchHit: def _normalize_search_text(value: str) -> str: - """Normalisiert Text für robuste, wortbasierte Suche/Filter. - - Wir ersetzen Nicht-Alphanumerisches durch Leerzeichen und kollabieren Whitespace. - Dadurch kann z.B. "Star Trek: Lower Decks – Der Film" sauber auf Tokens gematcht werden. - """ - - value = (value or "").casefold() - value = re.sub(r"[^a-z0-9]+", " ", value) - value = re.sub(r"\s+", " ", value).strip() - return value + return _shared_normalize_search_text(value) def _matches_query(query: str, *, title: str, description: str) -> bool: - normalized_query = _normalize_search_text(query) - if not normalized_query: - return False - haystack = f" {_normalize_search_text(title)} " - return f" {normalized_query} " in haystack + _ = description + return _shared_matches_query(query, title=title) def _strip_der_film_suffix(title: str) -> str: diff --git a/addon/resolveurl_backend.py b/addon/resolveurl_backend.py index 3433316..3f8b07e 100644 --- a/addon/resolveurl_backend.py +++ b/addon/resolveurl_backend.py @@ -6,24 +6,71 @@ zu einer abspielbaren Media-URL (inkl. evtl. Header-Suffix) aufgelöst werden. from __future__ import annotations +import importlib +import os +import sys from typing import Optional _LAST_RESOLVE_ERROR = "" +def _debug_log(message: str) -> None: + line = f"[ViewIt][ResolveURL] {message}" + try: + import xbmc # type: ignore + + xbmc.log(line, xbmc.LOGDEBUG) + except Exception: + return + + +def _append_addon_lib_path(addon_id: str) -> bool: + try: + import xbmcaddon # type: ignore + import xbmcvfs # type: ignore + + addon = xbmcaddon.Addon(addon_id) + addon_path = addon.getAddonInfo("path") + lib_path = xbmcvfs.translatePath(os.path.join(addon_path, "lib")) + if lib_path and lib_path not in sys.path: + sys.path.append(lib_path) + return bool(lib_path) + except Exception: + return False + + def get_last_error() -> str: return str(_LAST_RESOLVE_ERROR or "") +def _import_resolveurl(): + try: + return importlib.import_module("resolveurl") + except Exception as exc: + _debug_log(f"import resolveurl failed (direct): {exc}") + + # Kodi should load transitive deps, but some runtimes miss sys.path entries. + _append_addon_lib_path("script.module.resolveurl") + _append_addon_lib_path("script.module.kodi-six") + _append_addon_lib_path("script.module.six") + try: + return importlib.import_module("resolveurl") + except Exception as exc: + _debug_log(f"import resolveurl failed (with addon lib paths): {exc}") + return None + + def resolve(url: str) -> Optional[str]: global _LAST_RESOLVE_ERROR _LAST_RESOLVE_ERROR = "" if not url: + _debug_log("resolve() skipped (empty url)") return None - try: - import resolveurl # type: ignore - except Exception: + _debug_log(f"input: {url}") + resolveurl = _import_resolveurl() + if resolveurl is None: _LAST_RESOLVE_ERROR = "resolveurl missing" + _debug_log("result: (resolveurl missing)") return None try: @@ -33,28 +80,35 @@ def resolve(url: str) -> Optional[str]: valid = getattr(hmf, "valid_url", None) if callable(valid) and not valid(): _LAST_RESOLVE_ERROR = "invalid url" + _debug_log("result: (invalid url for HostedMediaFile)") return None resolver = getattr(hmf, "resolve", None) if callable(resolver): result = resolver() if result: + _debug_log(f"result: {result}") return str(result) _LAST_RESOLVE_ERROR = "unresolved" + _debug_log("result: (HostedMediaFile unresolved)") return None except Exception as exc: _LAST_RESOLVE_ERROR = str(exc or "") - pass + _debug_log(f"HostedMediaFile error: {_LAST_RESOLVE_ERROR}") try: resolve_fn = getattr(resolveurl, "resolve", None) if callable(resolve_fn): result = resolve_fn(url) if result: + _debug_log(f"result: {result}") return str(result) _LAST_RESOLVE_ERROR = "unresolved" + _debug_log("result: (resolve() unresolved)") return None except Exception as exc: _LAST_RESOLVE_ERROR = str(exc or "") + _debug_log(f"resolve() error: {_LAST_RESOLVE_ERROR}") return None + _debug_log("result: (no resolver path)") return None diff --git a/addon/search_utils.py b/addon/search_utils.py new file mode 100644 index 0000000..1a0e89b --- /dev/null +++ b/addon/search_utils.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import re + + +def normalize_search_text(value: str) -> str: + """Normalisiert Text fuer wortbasierte Suche. + + Gemeinsames Verhalten: + - lower-case + - Nicht-Alphanumerisches -> Leerzeichen + - mehrfachen Whitespace kollabieren + """ + + value = (value or "").casefold() + value = re.sub(r"[^a-z0-9]+", " ", value) + value = re.sub(r"\s+", " ", value).strip() + return value + + +def matches_query(query: str, *, title: str) -> bool: + """True, wenn der normalisierte Titel den normalisierten Query als ganzes Token enthaelt.""" + + normalized_query = normalize_search_text(query) + if not normalized_query: + return False + haystack = f" {normalize_search_text(title)} " + return f" {normalized_query} " in haystack + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a623763 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,65 @@ +## ViewIT Architekturüberblick + +Dieses Dokument fasst die wichtigsten Kernmodule des Addons und ihre Aufgaben zusammen. +Es ergänzt die Detaildokumente `DEFAULT_ROUTER.md` und `PLUGIN_SYSTEM.md`. + +### Ebenen und Verantwortlichkeiten + +- **Router (`addon/default.py`)** + - Einstiegspunkt aus Kodi (Parsing von `sys.argv`). + - Lädt und verwaltet alle Plugins (Discovery, Instanziierung, Fehlerisolation). + - Baut die Kodi-Menüs (Titel-, Staffel-, Episodenlisten, Spezialmenüs). + - Mapped UI‑Aktionen (`search`, `seasons`, `episodes`, `play_*`) auf Plugin-Methoden. + - Startet Playback und hält Kodi-Playstate konsistent (Resume/Watched). + +- **Plugin-Vertrag (`addon/plugin_interface.py`)** + - Definiert `BasisPlugin` als zentrale abstrakte Basisklasse. + - Kern-API: + - `search_titles(query, progress_callback?)` + - `seasons_for(title)` + - `episodes_for(title, season)` + - Optionale Fähigkeiten: + - Stream-Auflösung (`stream_link_for`, `resolve_stream_link`) + - Metadaten (`metadata_for`, `genres`, `titles_for_genre`, `popular_series`, `capabilities`). + - Dient als Referenz für alle konkreten Provider in `addon/plugins/`. + +- **Plugin-Hilfen (`addon/plugin_helpers.py`)** + - Zugriff auf Addon-Settings als String/Bool/Int (robust, auch außerhalb von Kodi). + - Optionale URL‑Benachrichtigungen im UI (`notify_url`). + - Strukturierte Logging-Helfer (`log_url`, `log_error`, `dump_response_html`) mit Rotationslogik. + - Normalisierung von speziellen Stream-URLs (`normalize_resolved_stream_url`). + - Fokus: Wiederverwendbare Infrastruktur für alle Plugins, ohne deren Kernlogik zu vermischen. + +- **HTTP Session Pool (`addon/http_session_pool.py`)** + - Verwaltet wiederverwendete `requests.Session`‑Instanzen pro Schlüssel. + - Ziel: TCP‑Verbindungen und Cookies über mehrere Requests hinweg wiederverwenden. + - Bietet `get_requests_session(key, headers?)` und `close_all_sessions()`. + - Wird von Plugins oder Hilfsmodulen genutzt, die viele HTTP‑Aufrufe pro Sitzung machen. + +- **TMDB-Integration (`addon/tmdb.py`)** + - Kapselt alle Zugriffe auf die TMDB‑API (TV‑Shows, Staffeln, Episoden, Filme). + - Enthält Datenklassen für Cast‑Mitglieder, Shows, Staffeln und Filme. + - Nutzt ein threadlokales `requests.Session`‑Pooling für parallele Metadatenabfragen. + - Wird vom Router genutzt, um Plugin-Metadaten optional mit TMDB‑Daten anzureichern. + +- **Metadaten-Helfer (`addon/metadata_utils.py`)** + - Berechnet plugin-spezifische Setting‑IDs für Metadatenquellen. + - Entscheidet pro Plugin und User‑Setting, ob Quelle, TMDB oder Mix bevorzugt wird. + - Sammelt Metadaten aus Plugins (`collect_plugin_metadata`) und merged sie mit TMDB (`merge_metadata`). + - Liefert Signale, wann ein TMDB‑Fallback nötig ist (`needs_tmdb`). + +- **ResolveURL Backend (`addon/resolveurl_backend.py`)** + - Optionales Backend, das `script.module.resolveurl` nutzt, wenn installiert. + - Versucht Hoster‑Links in abspielbare Media‑URLs aufzulösen. + - Speichert den letzten Fehlerzustand (`get_last_error`) für Logging oder User‑Feedback. + - Ist vollständig optional und bricht das Addon nicht, wenn ResolveURL fehlt. + +- **Regex-Muster (`addon/regex_patterns.py`)** + - Zentrale Sammlung wiederverwendeter Regulärer Ausdrücke (Staffel/Episoden‑Tags, Ziffern etc.). + - Ziel: Konsistenz und Vermeidung von fehleranfälligem Copy/Paste in Plugins. + +- **Plugins (`addon/plugins/*.py`)** + - Konkrete Integrationen zu einzelnen Providern (z.B. Serien-/Filmportale). + - Implementieren `BasisPlugin` und optional zusätzliche Capabilities. + - Verwenden die oben beschriebenen Hilfs‑ und Infrastrukturmodule. + diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md index b167134..e5f2607 100644 --- a/docs/PLUGIN_DEVELOPMENT.md +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -10,20 +10,26 @@ Diese Datei zeigt, wie Plugins im Projekt aufgebaut sind und wie sie mit dem Rou ## Pflichtmethoden Jedes Plugin implementiert: -- `async search_titles(query: str) -> list[str]` +- `async search_titles(query: str, progress_callback: Callable[[str, Optional[int]], Any] | None = None) -> list[str>` - `seasons_for(title: str) -> list[str]` - `episodes_for(title: str, season: str) -> list[str]` ## Wichtige optionale Methoden +- `capabilities()` +- `genres()` +- `popular_series()` +- `latest_episodes(page: int = 1)` +- `titles_for_genre(genre: str)` +- `titles_for_genre_page(genre: str, page: int)` +- `titles_for_genre_group_page(...)` / `genre_has_more(...)` (Paging / Alphabet-Gruppen) - `stream_link_for(...)` -- `resolve_stream_link(...)` -- `metadata_for(...)` +- `stream_link_for_url(...)` - `available_hosters_for(...)` +- `available_hosters_for_url(...)` +- `episode_url_for(...)` - `series_url_for_title(...)` - `remember_series_url(...)` -- `episode_url_for(...)` -- `available_hosters_for_url(...)` -- `stream_link_for_url(...)` +- `metadata_for(...)` ## Film Provider Standard Wenn keine echten Staffeln existieren: @@ -47,6 +53,12 @@ Aktuelle Regeln fuer Suchtreffer: - Keine Teilwort Treffer im selben Wort - Beschreibungen nicht fuer Match nutzen +Siehe als Referenz: +- `addon/plugins/_template_plugin.py` (Minimal-Template) +- `addon/plugins/serienstream_plugin.py` +- `addon/plugins/aniworld_plugin.py` +- `addon/plugins/topstreamfilm_plugin.py` + ## Settings Pro Plugin meist `*_base_url`. Beispiele: diff --git a/pyproject.toml b/pyproject.toml index 884d2d4..c15da68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,3 +18,16 @@ omit = [ [tool.coverage.report] show_missing = true skip_empty = true + +[tool.ruff] +line-length = 120 +target-version = "py311" +extend-exclude = ["dist", ".venv"] + +[tool.ruff.lint] +select = ["E", "F", "W"] +ignore = ["E501"] + +[tool.black] +line-length = 120 +target-version = ["py311"] diff --git a/requirements-dev.txt b/requirements-dev.txt index f03cefd..e6f03d0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,4 @@ pytest>=9,<10 pytest-cov>=5,<8 +ruff>=0.8,<0.9 +black>=24.0,<25.0