From 9df80240c4b0d79e8845d3ab1c57acf2dea97754 Mon Sep 17 00:00:00 2001 From: "itdrui.de" Date: Sun, 1 Feb 2026 19:45:51 +0100 Subject: [PATCH] Improve logging and docs --- addon/plugin_helpers.py | 135 +++++++++++++++++++++++--- addon/plugins/aniworld_plugin.py | 51 ++++++++-- addon/plugins/einschalten_plugin.py | 52 ++++++++-- addon/plugins/serienstream_plugin.py | 51 ++++++++-- addon/plugins/topstreamfilm_plugin.py | 58 ++++++++--- addon/resources/settings.xml | 28 +++++- docs/DEFAULT_ROUTER.md | 54 +++++++++++ docs/PLUGIN_DEVELOPMENT.md | 75 ++++++++++++++ docs/PLUGIN_SYSTEM.md | 4 + 9 files changed, 461 insertions(+), 47 deletions(-) create mode 100644 docs/DEFAULT_ROUTER.md create mode 100644 docs/PLUGIN_DEVELOPMENT.md diff --git a/addon/plugin_helpers.py b/addon/plugin_helpers.py index ef634c0..a21c038 100644 --- a/addon/plugin_helpers.py +++ b/addon/plugin_helpers.py @@ -54,10 +54,39 @@ def get_setting_bool(addon_id: str, setting_id: str, *, default: bool = False) - return default -def notify_url(addon_id: str, *, heading: str, url: str, enabled_setting_id: str) -> None: +def get_setting_int(addon_id: str, setting_id: str, *, default: int = 0) -> int: + if xbmcaddon is None: + return default + try: + addon = xbmcaddon.Addon(addon_id) + getter = getattr(addon, "getSettingInt", None) + if getter is not None: + return int(getter(setting_id)) + raw = addon.getSetting(setting_id) + return int(str(raw).strip()) + except Exception: + return default + + +def _is_logging_enabled(addon_id: str, *, global_setting_id: str, plugin_setting_id: Optional[str]) -> bool: + if not get_setting_bool(addon_id, global_setting_id, default=False): + return False + if plugin_setting_id: + return get_setting_bool(addon_id, plugin_setting_id, default=False) + return True + + +def notify_url( + addon_id: str, + *, + heading: str, + url: str, + enabled_setting_id: str, + plugin_setting_id: Optional[str] = None, +) -> None: if xbmcgui is None: return - if not get_setting_bool(addon_id, enabled_setting_id, default=False): + if not _is_logging_enabled(addon_id, global_setting_id=enabled_setting_id, plugin_setting_id=plugin_setting_id): return try: xbmcgui.Dialog().notification(heading, url, xbmcgui.NOTIFICATION_INFO, 3000) @@ -96,16 +125,92 @@ def _append_text_file(path: str, content: str) -> None: return -def log_url(addon_id: str, *, enabled_setting_id: str, log_filename: str, url: str, kind: str = "VISIT") -> None: - if not get_setting_bool(addon_id, enabled_setting_id, default=False): +def _rotate_log_file(path: str, *, max_bytes: int, max_files: int) -> None: + if max_bytes <= 0 or max_files <= 0: + return + try: + if not os.path.exists(path) or os.path.getsize(path) <= max_bytes: + return + except Exception: + return + try: + for index in range(max_files - 1, 0, -1): + older = f"{path}.{index}" + newer = f"{path}.{index + 1}" + if os.path.exists(older): + if index + 1 > max_files: + os.remove(older) + else: + os.replace(older, newer) + os.replace(path, f"{path}.1") + except Exception: + return + + +def _prune_dump_files(directory: str, *, prefix: str, max_files: int) -> None: + if not directory or max_files <= 0: + return + try: + entries = [ + os.path.join(directory, name) + for name in os.listdir(directory) + if name.startswith(prefix) and name.endswith(".html") + ] + if len(entries) <= max_files: + return + entries.sort(key=lambda path: os.path.getmtime(path)) + for path in entries[: len(entries) - max_files]: + try: + os.remove(path) + except Exception: + pass + except Exception: + return + + +def log_url( + addon_id: str, + *, + enabled_setting_id: str, + log_filename: str, + url: str, + kind: str = "VISIT", + request_id: Optional[str] = None, + plugin_setting_id: Optional[str] = None, + max_mb_setting_id: str = "log_max_mb", + max_files_setting_id: str = "log_max_files", +) -> None: + if not _is_logging_enabled(addon_id, global_setting_id=enabled_setting_id, plugin_setting_id=plugin_setting_id): return timestamp = datetime.utcnow().isoformat(timespec="seconds") + "Z" - line = f"{timestamp}\t{kind}\t{url}\n" + request_part = f"\t{request_id}" if request_id else "" + line = f"{timestamp}\t{kind}{request_part}\t{url}\n" log_dir = _profile_logs_dir(addon_id) - if log_dir: - _append_text_file(os.path.join(log_dir, log_filename), line) - return - _append_text_file(os.path.join(os.path.dirname(__file__), log_filename), line) + path = os.path.join(log_dir, log_filename) if log_dir else os.path.join(os.path.dirname(__file__), log_filename) + max_mb = get_setting_int(addon_id, max_mb_setting_id, default=5) + max_files = get_setting_int(addon_id, max_files_setting_id, default=3) + _rotate_log_file(path, max_bytes=max_mb * 1024 * 1024, max_files=max_files) + _append_text_file(path, line) + + +def log_error( + addon_id: str, + *, + enabled_setting_id: str, + log_filename: str, + message: str, + request_id: Optional[str] = None, + plugin_setting_id: Optional[str] = None, +) -> None: + log_url( + addon_id, + enabled_setting_id=enabled_setting_id, + plugin_setting_id=plugin_setting_id, + log_filename=log_filename, + url=message, + kind="ERROR", + request_id=request_id, + ) def dump_response_html( @@ -115,14 +220,20 @@ def dump_response_html( url: str, body: str, filename_prefix: str, + request_id: Optional[str] = None, + plugin_setting_id: Optional[str] = None, + max_files_setting_id: str = "dump_max_files", ) -> None: - if not get_setting_bool(addon_id, enabled_setting_id, default=False): + if not _is_logging_enabled(addon_id, global_setting_id=enabled_setting_id, plugin_setting_id=plugin_setting_id): return timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f") digest = hashlib.md5(url.encode("utf-8")).hexdigest() # nosec - filename only filename = f"{filename_prefix}_{timestamp}_{digest}.html" log_dir = _profile_logs_dir(addon_id) path = os.path.join(log_dir, filename) if log_dir else os.path.join(os.path.dirname(__file__), filename) - content = f"\n{body or ''}" + request_line = f" request_id={request_id}" if request_id else "" + content = f"\n{body or ''}" + if log_dir: + max_files = get_setting_int(addon_id, max_files_setting_id, default=200) + _prune_dump_files(log_dir, prefix=filename_prefix, max_files=max_files) _append_text_file(path, content) - diff --git a/addon/plugins/aniworld_plugin.py b/addon/plugins/aniworld_plugin.py index 7d549b5..7629847 100644 --- a/addon/plugins/aniworld_plugin.py +++ b/addon/plugins/aniworld_plugin.py @@ -29,7 +29,7 @@ except ImportError: # pragma: no cover - allow running outside Kodi xbmcaddon = None from plugin_interface import BasisPlugin -from plugin_helpers import dump_response_html, get_setting_bool, get_setting_string, log_url, notify_url +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 @@ -49,6 +49,11 @@ ADDON_ID = "plugin.video.viewit" GLOBAL_SETTING_LOG_URLS = "debug_log_urls" GLOBAL_SETTING_DUMP_HTML = "debug_dump_html" GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info" +GLOBAL_SETTING_LOG_ERRORS = "debug_log_errors" +SETTING_LOG_URLS = "log_urls_aniworld" +SETTING_DUMP_HTML = "dump_html_aniworld" +SETTING_SHOW_URL_INFO = "show_url_info_aniworld" +SETTING_LOG_ERRORS = "log_errors_aniworld" HEADERS = { "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", @@ -124,12 +129,25 @@ def _absolute_url(href: str) -> str: def _log_url(url: str, *, kind: str = "VISIT") -> None: - log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="aniworld_urls.log", url=url, kind=kind) + log_url( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_URLS, + plugin_setting_id=SETTING_LOG_URLS, + log_filename="aniworld_urls.log", + url=url, + kind=kind, + ) def _log_visit(url: str) -> None: _log_url(url, kind="VISIT") - notify_url(ADDON_ID, heading="AniWorld", url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO) + notify_url( + ADDON_ID, + heading="AniWorld", + url=url, + enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO, + plugin_setting_id=SETTING_SHOW_URL_INFO, + ) def _log_parsed_url(url: str) -> None: @@ -140,12 +158,23 @@ def _log_response_html(url: str, body: str) -> None: dump_response_html( ADDON_ID, enabled_setting_id=GLOBAL_SETTING_DUMP_HTML, + plugin_setting_id=SETTING_DUMP_HTML, url=url, body=body, filename_prefix="aniworld_response", ) +def _log_error(message: str) -> None: + log_error( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_ERRORS, + plugin_setting_id=SETTING_LOG_ERRORS, + log_filename="aniworld_errors.log", + message=message, + ) + + def _normalize_search_text(value: str) -> str: value = (value or "").casefold() value = re.sub(r"[^a-z0-9]+", " ", value) @@ -192,8 +221,12 @@ def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> Beautif _ensure_requests() _log_visit(url) sess = session or get_requests_session("aniworld", headers=HEADERS) - response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) - response.raise_for_status() + try: + response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + except Exception as exc: + _log_error(f"GET {url} failed: {exc}") + raise if response.url and response.url != url: _log_url(response.url, kind="REDIRECT") _log_response_html(url, response.text) @@ -206,8 +239,12 @@ def _get_soup_simple(url: str) -> BeautifulSoupT: _ensure_requests() _log_visit(url) sess = get_requests_session("aniworld", headers=HEADERS) - response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) - response.raise_for_status() + try: + response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + except Exception as exc: + _log_error(f"GET {url} failed: {exc}") + raise if response.url and response.url != url: _log_url(response.url, kind="REDIRECT") _log_response_html(url, response.text) diff --git a/addon/plugins/einschalten_plugin.py b/addon/plugins/einschalten_plugin.py index 285578d..32f1562 100644 --- a/addon/plugins/einschalten_plugin.py +++ b/addon/plugins/einschalten_plugin.py @@ -30,13 +30,18 @@ except ImportError: # pragma: no cover - allow running outside Kodi xbmcaddon = None from plugin_interface import BasisPlugin -from plugin_helpers import dump_response_html, get_setting_bool, log_url, notify_url +from plugin_helpers import dump_response_html, get_setting_bool, log_error, log_url, notify_url ADDON_ID = "plugin.video.viewit" SETTING_BASE_URL = "einschalten_base_url" GLOBAL_SETTING_LOG_URLS = "debug_log_urls" GLOBAL_SETTING_DUMP_HTML = "debug_dump_html" GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info" +GLOBAL_SETTING_LOG_ERRORS = "debug_log_errors" +SETTING_LOG_URLS = "log_urls_einschalten" +SETTING_DUMP_HTML = "dump_html_einschalten" +SETTING_SHOW_URL_INFO = "show_url_info_einschalten" +SETTING_LOG_ERRORS = "log_errors_einschalten" DEFAULT_BASE_URL = "" DEFAULT_INDEX_PATH = "/" @@ -147,16 +152,36 @@ def _extract_ng_state_payload(html: str) -> Dict[str, Any]: def _notify_url(url: str) -> None: - notify_url(ADDON_ID, heading="einschalten", url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO) + notify_url( + ADDON_ID, + heading="Einschalten", + url=url, + enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO, + plugin_setting_id=SETTING_SHOW_URL_INFO, + ) def _log_url(url: str, *, kind: str = "VISIT") -> None: - log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="einschalten_urls.log", url=url, kind=kind) + log_url( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_URLS, + plugin_setting_id=SETTING_LOG_URLS, + log_filename="einschalten_urls.log", + url=url, + kind=kind, + ) def _log_debug_line(message: str) -> None: try: - log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="einschalten_debug.log", url=message, kind="DEBUG") + log_url( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_URLS, + plugin_setting_id=SETTING_LOG_URLS, + log_filename="einschalten_debug.log", + url=message, + kind="DEBUG", + ) except Exception: pass @@ -168,6 +193,7 @@ def _log_titles(items: list[MovieItem], *, context: str) -> None: log_url( ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, + plugin_setting_id=SETTING_LOG_URLS, log_filename="einschalten_titles.log", url=f"{context}:count={len(items)}", kind="TITLE", @@ -176,6 +202,7 @@ def _log_titles(items: list[MovieItem], *, context: str) -> None: log_url( ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, + plugin_setting_id=SETTING_LOG_URLS, log_filename="einschalten_titles.log", url=f"{context}:id={item.id} title={item.title}", kind="TITLE", @@ -188,11 +215,22 @@ def _log_response_html(url: str, body: str) -> None: dump_response_html( ADDON_ID, enabled_setting_id=GLOBAL_SETTING_DUMP_HTML, + plugin_setting_id=SETTING_DUMP_HTML, url=url, body=body, filename_prefix="einschalten_response", ) + +def _log_error(message: str) -> None: + log_error( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_ERRORS, + plugin_setting_id=SETTING_LOG_ERRORS, + log_filename="einschalten_errors.log", + message=message, + ) + def _u_matches(value: Any, expected_path: str) -> bool: raw = (value or "").strip() if not raw: @@ -616,7 +654,8 @@ class EinschaltenPlugin(BasisPlugin): _log_response_html(resp.url or url, resp.text) self._detail_html_by_id[movie_id] = resp.text or "" return resp.text or "" - except Exception: + except Exception as exc: + _log_error(f"GET {url} failed: {exc}") return "" def _fetch_watch_payload(self, movie_id: int) -> dict[str, object]: @@ -637,7 +676,8 @@ class EinschaltenPlugin(BasisPlugin): _log_response_html(resp.url or url, resp.text) data = resp.json() return dict(data) if isinstance(data, dict) else {} - except Exception: + except Exception as exc: + _log_error(f"GET {url} failed: {exc}") return {} def _watch_stream_url(self, movie_id: int) -> str: diff --git a/addon/plugins/serienstream_plugin.py b/addon/plugins/serienstream_plugin.py index 0d3afc5..8a0544f 100644 --- a/addon/plugins/serienstream_plugin.py +++ b/addon/plugins/serienstream_plugin.py @@ -37,7 +37,7 @@ except ImportError: # pragma: no cover - allow running outside Kodi xbmcgui = None from plugin_interface import BasisPlugin -from plugin_helpers import dump_response_html, get_setting_bool, get_setting_string, log_url, notify_url +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 SEASON_EPISODE_TAG, SEASON_EPISODE_URL @@ -57,6 +57,11 @@ ADDON_ID = "plugin.video.viewit" GLOBAL_SETTING_LOG_URLS = "debug_log_urls" GLOBAL_SETTING_DUMP_HTML = "debug_dump_html" GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info" +GLOBAL_SETTING_LOG_ERRORS = "debug_log_errors" +SETTING_LOG_URLS = "log_urls_serienstream" +SETTING_DUMP_HTML = "dump_html_serienstream" +SETTING_SHOW_URL_INFO = "show_url_info_serienstream" +SETTING_LOG_ERRORS = "log_errors_serienstream" HEADERS = { "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", @@ -169,11 +174,24 @@ def _get_setting_bool(setting_id: str, *, default: bool = False) -> bool: def _notify_url(url: str) -> None: - notify_url(ADDON_ID, heading="Serienstream", url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO) + notify_url( + ADDON_ID, + heading="Serienstream", + url=url, + enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO, + plugin_setting_id=SETTING_SHOW_URL_INFO, + ) def _log_url(url: str, *, kind: str = "VISIT") -> None: - log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="serienstream_urls.log", url=url, kind=kind) + log_url( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_URLS, + plugin_setting_id=SETTING_LOG_URLS, + log_filename="serienstream_urls.log", + url=url, + kind=kind, + ) def _log_parsed_url(url: str) -> None: @@ -184,12 +202,23 @@ def _log_response_html(url: str, body: str) -> None: dump_response_html( ADDON_ID, enabled_setting_id=GLOBAL_SETTING_DUMP_HTML, + plugin_setting_id=SETTING_DUMP_HTML, url=url, body=body, filename_prefix="s_to_response", ) +def _log_error(message: str) -> None: + log_error( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_ERRORS, + plugin_setting_id=SETTING_LOG_ERRORS, + log_filename="serienstream_errors.log", + message=message, + ) + + def _ensure_requests() -> None: if requests is None or BeautifulSoup is None: raise RuntimeError("requests/bs4 sind nicht verfuegbar.") @@ -213,8 +242,12 @@ def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> Beautif _ensure_requests() _log_visit(url) sess = session or get_requests_session("serienstream", headers=HEADERS) - response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) - response.raise_for_status() + try: + response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + except Exception as exc: + _log_error(f"GET {url} failed: {exc}") + raise if response.url and response.url != url: _log_url(response.url, kind="REDIRECT") _log_response_html(url, response.text) @@ -227,8 +260,12 @@ def _get_soup_simple(url: str) -> BeautifulSoupT: _ensure_requests() _log_visit(url) sess = get_requests_session("serienstream", headers=HEADERS) - response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) - response.raise_for_status() + try: + response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + except Exception as exc: + _log_error(f"GET {url} failed: {exc}") + raise if response.url and response.url != url: _log_url(response.url, kind="REDIRECT") _log_response_html(url, response.text) diff --git a/addon/plugins/topstreamfilm_plugin.py b/addon/plugins/topstreamfilm_plugin.py index 53c50c4..3334f59 100644 --- a/addon/plugins/topstreamfilm_plugin.py +++ b/addon/plugins/topstreamfilm_plugin.py @@ -44,7 +44,7 @@ except ImportError: # pragma: no cover - allow running outside Kodi xbmcgui = None from plugin_interface import BasisPlugin -from plugin_helpers import dump_response_html, get_setting_bool, log_url, notify_url +from plugin_helpers import dump_response_html, get_setting_bool, log_error, log_url, notify_url from regex_patterns import DIGITS if TYPE_CHECKING: # pragma: no cover @@ -61,6 +61,11 @@ DEFAULT_BASE_URL = "https://www.meineseite" GLOBAL_SETTING_LOG_URLS = "debug_log_urls" GLOBAL_SETTING_DUMP_HTML = "debug_dump_html" GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info" +GLOBAL_SETTING_LOG_ERRORS = "debug_log_errors" +SETTING_LOG_URLS = "log_urls_topstreamfilm" +SETTING_DUMP_HTML = "dump_html_topstreamfilm" +SETTING_SHOW_URL_INFO = "show_url_info_topstreamfilm" +SETTING_LOG_ERRORS = "log_errors_topstreamfilm" SETTING_GENRE_MAX_PAGES = "topstream_genre_max_pages" DEFAULT_TIMEOUT = 20 DEFAULT_PREFERRED_HOSTERS = ["supervideo", "dropload", "voe"] @@ -348,20 +353,43 @@ class TopstreamfilmPlugin(BasisPlugin): return default def _notify_url(self, url: str) -> None: - notify_url(ADDON_ID, heading=self.name, url=url, enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO) + notify_url( + ADDON_ID, + heading=self.name, + url=url, + enabled_setting_id=GLOBAL_SETTING_SHOW_URL_INFO, + plugin_setting_id=SETTING_SHOW_URL_INFO, + ) def _log_url(self, url: str, *, kind: str = "VISIT") -> None: - log_url(ADDON_ID, enabled_setting_id=GLOBAL_SETTING_LOG_URLS, log_filename="topstream_urls.log", url=url, kind=kind) + log_url( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_URLS, + plugin_setting_id=SETTING_LOG_URLS, + log_filename="topstream_urls.log", + url=url, + kind=kind, + ) def _log_response_html(self, url: str, body: str) -> None: dump_response_html( ADDON_ID, enabled_setting_id=GLOBAL_SETTING_DUMP_HTML, + plugin_setting_id=SETTING_DUMP_HTML, url=url, body=body, filename_prefix="topstream_response", ) + def _log_error(self, message: str) -> None: + log_error( + ADDON_ID, + enabled_setting_id=GLOBAL_SETTING_LOG_ERRORS, + plugin_setting_id=SETTING_LOG_ERRORS, + log_filename="topstream_errors.log", + message=message, + ) + def capabilities(self) -> set[str]: return {"genres", "popular_series"} @@ -557,8 +585,12 @@ class TopstreamfilmPlugin(BasisPlugin): session = self._get_session() self._log_url(url, kind="VISIT") self._notify_url(url) - response = session.get(url, timeout=DEFAULT_TIMEOUT) - response.raise_for_status() + try: + response = session.get(url, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + except Exception as exc: + self._log_error(f"GET {url} failed: {exc}") + raise self._log_url(response.url, kind="OK") self._log_response_html(response.url, response.text) return BeautifulSoup(response.text, "html.parser") @@ -803,12 +835,16 @@ class TopstreamfilmPlugin(BasisPlugin): request_url = f"{url}?{urlencode(params)}" self._log_url(request_url, kind="GET") self._notify_url(request_url) - response = session.get( - url, - params=params, - timeout=DEFAULT_TIMEOUT, - ) - response.raise_for_status() + try: + response = session.get( + url, + params=params, + timeout=DEFAULT_TIMEOUT, + ) + response.raise_for_status() + except Exception as exc: + self._log_error(f"GET {request_url} failed: {exc}") + raise self._log_url(response.url, kind="OK") self._log_response_html(response.url, response.text) diff --git a/addon/resources/settings.xml b/addon/resources/settings.xml index 6fdb793..fc3e81f 100644 --- a/addon/resources/settings.xml +++ b/addon/resources/settings.xml @@ -1,9 +1,29 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/DEFAULT_ROUTER.md b/docs/DEFAULT_ROUTER.md new file mode 100644 index 0000000..61a2aed --- /dev/null +++ b/docs/DEFAULT_ROUTER.md @@ -0,0 +1,54 @@ +# ViewIT – Hauptlogik (`addon/default.py`) + +Dieses Dokument beschreibt den Einstiegspunkt des Addons und die zentrale Steuerlogik. + +## Aufgabe der Datei +`addon/default.py` ist der Router des Addons. Er: +- lädt die Plugin‑Module dynamisch, +- stellt die Kodi‑Navigation bereit, +- übersetzt UI‑Aktionen in Plugin‑Aufrufe, +- startet die Wiedergabe und verwaltet Playstate/Resume. + +## Ablauf (high level) +1. **Plugin‑Discovery**: Lädt alle `addon/plugins/*.py` (ohne `_`‑Prefix) und instanziiert Klassen, die von `BasisPlugin` erben. +2. **Navigation**: Baut Kodi‑Listen (Serien/Staffeln/Episoden) auf Basis der Plugin‑Antworten. +3. **Playback**: Holt Stream‑Links aus dem Plugin und startet die Wiedergabe. +4. **Playstate**: Speichert Resume‑Daten lokal (`playstate.json`) und setzt `playcount`/Resume‑Infos. + +## Routing & Aktionen +Die Datei arbeitet mit URL‑Parametern (Kodi‑Plugin‑Standard). Typische Aktionen: +- `search` → Suche über ein Plugin +- `seasons` → Staffeln für einen Titel +- `episodes` → Episoden für eine Staffel +- `play` → Stream‑Link auflösen und abspielen + +Die genaue Aktion wird aus den Query‑Parametern gelesen und an das entsprechende Plugin delegiert. + +## Playstate (Resume/Watched) +- **Speicherort**: `playstate.json` im Addon‑Profilordner. +- **Key**: Kombination aus Plugin‑Name, Titel, Staffel, Episode. +- **Verwendung**: + - `playcount` wird gesetzt, wenn „gesehen“ markiert ist. + - `resume_position`/`resume_total` werden gesetzt, wenn vorhanden. + +## Wichtige Hilfsfunktionen +- **Plugin‑Loader**: findet & instanziiert Plugins. +- **UI‑Helper**: setzt Content‑Type, baut Verzeichnisseinträge. +- **Playstate‑Helper**: `_load_playstate`, `_save_playstate`, `_apply_playstate_to_info`. + +## Fehlerbehandlung +- Plugin‑Importfehler werden isoliert behandelt, damit das Addon nicht komplett ausfällt. +- Netzwerk‑Fehler werden in Plugins abgefangen, `default.py` sollte nur saubere Fehlermeldungen weitergeben. + +## Debugging +- Globale Debug‑Settings werden über `addon/resources/settings.xml` gesteuert. +- Plugins loggen URLs/HTML optional (siehe jeweilige Plugin‑Doku). + +## Änderungen & Erweiterungen +Für neue Aktionen: +1. Neue Aktion im Router registrieren. +2. UI‑Einträge passend anlegen. +3. Entsprechende Plugin‑Methode definieren oder erweitern. + +## Hinweis zur Erstellung +Teile dieser Dokumentation wurden KI‑gestützt erstellt und bei Bedarf manuell angepasst. diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md new file mode 100644 index 0000000..6f7b3cc --- /dev/null +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -0,0 +1,75 @@ +# ViewIT – Entwicklerdoku Plugins (`addon/plugins/*_plugin.py`) + +Diese Doku beschreibt, wie Plugins im ViewIT‑Addon aufgebaut sind und wie neue Provider‑Integrationen entwickelt werden. + +## Grundlagen +- Jedes Plugin ist eine einzelne Datei unter `addon/plugins/`. +- Dateinamen **ohne** `_`‑Prefix werden automatisch geladen. +- Jede Datei enthält eine Klasse, die von `BasisPlugin` erbt. + +## Pflicht‑Methoden (BasisPlugin) +Jedes Plugin muss diese Methoden implementieren: +- `async search_titles(query: str) -> list[str]` +- `seasons_for(title: str) -> list[str]` +- `episodes_for(title: str, season: str) -> list[str]` + +## Optionale Features (Capabilities) +Über `capabilities()` kann das Plugin zusätzliche Funktionen anbieten: +- `popular_series` → `popular_series()` +- `genres` → `genres()` + `titles_for_genre(genre)` +- `latest_episodes` → `latest_episodes(page=1)` + +## Empfohlene Struktur +- Konstanten für URLs/Endpoints (BASE_URL, Pfade, Templates) +- `requests` + `bs4` optional (fehlt beides, Plugin sollte sauber deaktivieren) +- Helper‑Funktionen für Parsing und Normalisierung +- Caches für Such‑, Staffel‑ und Episoden‑Daten + +## Suche (aktuelle Policy) +- **Nur Titel‑Matches** +- **Substring‑Match** nach Normalisierung (Lowercase + Nicht‑Alnum → Leerzeichen) +- Keine Beschreibung/Plot/Meta für Matches + +## Namensgebung +- Plugin‑Klassenname: `XxxPlugin` +- Anzeigename (Property `name`): **mit Großbuchstaben beginnen** (z. B. `Serienstream`, `Einschalten`) + +## Settings pro Plugin +Standard: `*_base_url` (Domain / BASE_URL) +- Beispiele: + - `serienstream_base_url` + - `aniworld_base_url` + - `einschalten_base_url` + - `topstream_base_url` + +## Playback +- Wenn möglich `stream_link_for(...)` implementieren. +- Optional `available_hosters_for(...)`/`resolve_stream_link(...)` für Hoster‑Auflösung. + +## Debugging +Global gesteuert über Settings: +- `debug_log_urls` +- `debug_dump_html` +- `debug_show_url_info` + +Plugins sollten die Helper aus `addon/plugin_helpers.py` nutzen: +- `log_url(...)` +- `dump_response_html(...)` +- `notify_url(...)` + +## Template +`addon/plugins/_template_plugin.py` dient als Startpunkt für neue Provider. + +## Build & Test +- ZIP bauen: `./scripts/build_kodi_zip.sh` +- Addon‑Ordner: `./scripts/build_install_addon.sh` + +## Beispiel‑Checkliste +- [ ] `name` korrekt gesetzt +- [ ] `*_base_url` in Settings vorhanden +- [ ] Suche matcht nur Titel +- [ ] Fehlerbehandlung und Timeouts vorhanden +- [ ] Optional: Caches für Performance + +## Hinweis zur Erstellung +Teile dieser Dokumentation wurden KI‑gestützt erstellt und bei Bedarf manuell angepasst. diff --git a/docs/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md index 02ab643..bdf7614 100644 --- a/docs/PLUGIN_SYSTEM.md +++ b/docs/PLUGIN_SYSTEM.md @@ -6,6 +6,10 @@ Dieses Dokument beschreibt, wie das Plugin-System von **ViewIt** funktioniert un ViewIt lädt Provider-Integrationen dynamisch aus `addon/plugins/*.py`. Jede Datei enthält eine Klasse, die von `BasisPlugin` erbt. Beim Start werden alle Plugins instanziiert und nur aktiv genutzt, wenn sie verfügbar sind. +Weitere Details: +- `docs/DEFAULT_ROUTER.md` (Hauptlogik in `addon/default.py`) +- `docs/PLUGIN_DEVELOPMENT.md` (Entwicklerdoku für Plugins) + ### Aktuelle Plugins - `serienstream_plugin.py` – Serienstream (s.to)