From 16e4b5f2611456296455d46cef3fc30349f74b9f Mon Sep 17 00:00:00 2001 From: "itdrui.de" Date: Tue, 24 Feb 2026 16:18:44 +0100 Subject: [PATCH] dev: harden resolver bootstrap and simplify update settings --- CHANGELOG-DEV.md | 9 ++ README.md | 1 + addon/addon.xml | 3 +- addon/default.py | 182 +++++++++++++++++++++---------- addon/resolveurl_backend.py | 11 +- addon/resources/settings.xml | 14 +-- scripts/build_local_kodi_repo.sh | 2 + scripts/verify_repo_artifacts.py | 147 +++++++++++++++++++++++++ 8 files changed, 298 insertions(+), 71 deletions(-) create mode 100755 scripts/verify_repo_artifacts.py diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 7d88d10..1e14cc5 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,5 +1,14 @@ # Changelog (Dev) +## 0.1.63-dev - 2026-02-24 + +- ResolveURL ist jetzt eine weiche Abhaengigkeit: ViewIt installiert auch ohne vorinstalliertes ResolveURL. +- Neuer Settings-Action: `ResolveURL installieren/reparieren`. +- Optionales Auto-Bootstrap: ResolveURL kann beim Start automatisch nachinstalliert werden. +- Wiedergabe versucht bei fehlendem ResolveURL einmalig eine stille Nachinstallation und loest dann erneut auf. +- Update-Settings aufgeraeumt: Fokus auf installierte Version, Kanalstatus und verfuegbare Version im gewaehlten Kanal. +- Repo-Validierung als Script hinzugefuegt (`scripts/verify_repo_artifacts.py`) und in den lokalen Repo-Build eingebunden. + ## 0.1.62-dev - 2026-02-24 - Neuer Dev-Stand fuer Genre-Performance (Serienstream). diff --git a/README.md b/README.md index 7acafb8..6220385 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Es durchsucht Provider und startet Streams. ## Lokales Kodi Repository - Repository bauen: `./scripts/build_local_kodi_repo.sh` - Repository starten: `./scripts/serve_local_kodi_repo.sh` +- Repo-Artefakte pruefen: `./scripts/verify_repo_artifacts.py ./dist/repo` - Standard URL: `http://127.0.0.1:8080/repo/addons.xml` - Eigene URL beim Build: `REPO_BASE_URL=http://:/repo ./scripts/build_local_kodi_repo.sh` diff --git a/addon/addon.xml b/addon/addon.xml index d05ecd1..7966607 100644 --- a/addon/addon.xml +++ b/addon/addon.xml @@ -1,10 +1,9 @@ - + - video diff --git a/addon/default.py b/addon/default.py index 4233519..a445734 100644 --- a/addon/default.py +++ b/addon/default.py @@ -954,12 +954,6 @@ def _add_directory_item( xbmcplugin.addDirectoryItem(handle=handle, url=url, listitem=item, isFolder=is_folder) -def _plugin_version(plugin: BasisPlugin) -> str: - raw = getattr(plugin, "version", "0.0.0") - text = str(raw or "").strip() - return text or "0.0.0" - - def _normalize_update_info_url(raw: str) -> str: value = str(raw or "").strip() default = "http://127.0.0.1:8080/repo/addons.xml" @@ -976,6 +970,8 @@ UPDATE_CHANNEL_CUSTOM = 2 AUTO_UPDATE_INTERVAL_SEC = 6 * 60 * 60 UPDATE_HTTP_TIMEOUT_SEC = 8 UPDATE_ADDON_ID = "plugin.video.viewit" +RESOLVEURL_ADDON_ID = "script.module.resolveurl" +RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC = 6 * 60 * 60 def _selected_update_channel() -> int: @@ -1030,10 +1026,7 @@ def _resolve_update_info_url() -> str: raw = _get_setting_string("update_repo_url") else: raw = _get_setting_string("update_repo_url_main") - info_url = _normalize_update_info_url(raw) - # Legacy-Setting beibehalten, damit bestehende Installationen und alte Builds weiterlaufen. - _set_setting_string("update_repo_url", info_url) - return info_url + return _normalize_update_info_url(raw) def _read_text_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> str: @@ -1247,25 +1240,10 @@ def _install_addon_version(info_url: str, version: str) -> bool: def _sync_update_channel_status_settings() -> None: channel = _selected_update_channel() - channel_label = _channel_label(channel) - selected_info_url = _resolve_update_info_url() - main_info_url = _normalize_update_info_url(_get_setting_string("update_repo_url_main")) - nightly_info_url = _normalize_update_info_url(_get_setting_string("update_repo_url_nightly")) - - available_main = _fetch_repo_addon_version(main_info_url) - available_nightly = _fetch_repo_addon_version(nightly_info_url) - if channel == UPDATE_CHANNEL_MAIN: - available_selected = available_main - elif channel == UPDATE_CHANNEL_NIGHTLY: - available_selected = available_nightly - else: - available_selected = _fetch_repo_addon_version(selected_info_url) - - _set_setting_string("update_active_channel", channel_label) + available_selected = _fetch_repo_addon_version(selected_info_url) + _set_setting_string("update_active_channel", _channel_label(channel)) _set_setting_string("update_active_repo_url", selected_info_url) - _set_setting_string("update_available_main", available_main) - _set_setting_string("update_available_nightly", available_nightly) _set_setting_string("update_available_selected", available_selected) @@ -1306,11 +1284,6 @@ def _update_repository_source(info_url: str) -> bool: return False -def _settings_key_for_plugin(name: str) -> str: - safe = re.sub(r"[^a-z0-9]+", "_", (name or "").strip().casefold()).strip("_") - return f"update_version_{safe}" if safe else "update_version_unknown" - - def _installed_addon_version_from_disk() -> str: if xbmcvfs is None: return "0.0.0" @@ -1328,6 +1301,96 @@ def _installed_addon_version_from_disk() -> str: return "0.0.0" +def _is_addon_installed(addon_id: str) -> bool: + addon_id = str(addon_id or "").strip() + if not addon_id: + return False + has_addon = getattr(xbmc, "getCondVisibility", None) + if callable(has_addon): + try: + return bool(has_addon(f"System.HasAddon({addon_id})")) + except Exception: + pass + if xbmcvfs is None: + return False + try: + addon_xml = xbmcvfs.translatePath(f"special://home/addons/{addon_id}/addon.xml") + except Exception: + return False + return bool(addon_xml and os.path.exists(addon_xml)) + + +def _sync_resolveurl_status_setting() -> None: + status = "Installiert" if _is_addon_installed(RESOLVEURL_ADDON_ID) else "Fehlt" + _set_setting_string("resolveurl_status", status) + + +def _install_kodi_addon(addon_id: str, *, wait_seconds: int) -> bool: + if _is_addon_installed(addon_id): + return True + builtin = getattr(xbmc, "executebuiltin", None) + if not callable(builtin): + return False + try: + builtin(f"InstallAddon({addon_id})") + builtin("UpdateLocalAddons") + except Exception as exc: + _log(f"InstallAddon fehlgeschlagen ({addon_id}): {exc}", xbmc.LOGWARNING) + return False + + if wait_seconds <= 0: + return _is_addon_installed(addon_id) + deadline = time.time() + max(1, int(wait_seconds)) + while time.time() < deadline: + if _is_addon_installed(addon_id): + return True + time.sleep(1) + return _is_addon_installed(addon_id) + + +def _ensure_resolveurl_installed(*, force: bool, silent: bool) -> bool: + if _is_addon_installed(RESOLVEURL_ADDON_ID): + _sync_resolveurl_status_setting() + return True + if not force and not _get_setting_bool("resolveurl_auto_install", default=True): + _sync_resolveurl_status_setting() + return False + + now = int(time.time()) + if not force: + last_try = _get_setting_int("resolveurl_last_ts", default=0) + if last_try > 0 and (now - last_try) < RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC: + return False + _set_setting_string("resolveurl_last_ts", str(now)) + + wait_seconds = 20 if force else 0 + ok = _install_kodi_addon(RESOLVEURL_ADDON_ID, wait_seconds=wait_seconds) + _sync_resolveurl_status_setting() + + if not silent: + if ok: + xbmcgui.Dialog().notification( + "ResolveURL", + "script.module.resolveurl ist installiert.", + xbmcgui.NOTIFICATION_INFO, + 4000, + ) + else: + xbmcgui.Dialog().notification( + "ResolveURL", + "Installation fehlgeschlagen. Bitte Repository/Netzwerk pruefen.", + xbmcgui.NOTIFICATION_ERROR, + 5000, + ) + return ok + + +def _maybe_auto_install_resolveurl(action: str | None) -> None: + if (action or "").strip(): + return + _ensure_resolveurl_installed(force=False, silent=True) + + def _sync_update_version_settings() -> None: addon_version = _installed_addon_version_from_disk() if addon_version == "0.0.0": @@ -1337,24 +1400,8 @@ def _sync_update_version_settings() -> None: addon_version = str(addon.getAddonInfo("version") or "0.0.0") except Exception: addon_version = "0.0.0" - _set_setting_string("update_version_addon", addon_version) _set_setting_string("update_installed_version", addon_version) - - versions = { - "update_version_serienstream": "-", - "update_version_aniworld": "-", - "update_version_einschalten": "-", - "update_version_topstreamfilm": "-", - "update_version_filmpalast": "-", - "update_version_doku_streams": "-", - } - for plugin in _discover_plugins().values(): - key = _settings_key_for_plugin(str(plugin.name)) - if key in versions: - versions[key] = _plugin_version(plugin) - for key, value in versions.items(): - _set_setting_string(key, value) - + _sync_resolveurl_status_setting() _sync_update_channel_status_settings() @@ -3543,14 +3590,18 @@ def _show_version_selector() -> None: except Exception: pass - confirm_choice = xbmcgui.Dialog().select( - "Version installieren", - [ - f"Installieren: {version}", - "Abbrechen", - ], - ) - if confirm_choice != 0: + dialog = xbmcgui.Dialog() + try: + confirmed = dialog.yesno( + "Version installieren", + f"Installiert: {installed}", + f"Ausgewaehlt: {version}", + yeslabel="Installieren", + nolabel="Abbrechen", + ) + except TypeError: + confirmed = dialog.yesno("Version installieren", f"Installiert: {installed}", f"Ausgewaehlt: {version}") + if not confirmed: return xbmcgui.Dialog().notification("Updates", f"Installation gestartet: {version}", xbmcgui.NOTIFICATION_INFO, 2500) @@ -3609,6 +3660,10 @@ def _is_cloudflare_challenge_error(message: str) -> bool: return "cloudflare" in text or "challenge" in text or "attention required" in text +def _is_resolveurl_missing_error(message: str) -> bool: + return str(message or "").strip().casefold() == "resolveurl missing" + + def _play_final_link( link: str, *, @@ -3743,6 +3798,11 @@ def _play_episode( resolved_link = plugin.resolve_stream_link(link) if not resolved_link: err = _resolveurl_last_error() + if _is_resolveurl_missing_error(err): + _log("ResolveURL fehlt: versuche Auto-Installation.", xbmc.LOGWARNING) + _ensure_resolveurl_installed(force=True, silent=True) + resolved_link = plugin.resolve_stream_link(link) + err = _resolveurl_last_error() if _is_cloudflare_challenge_error(err): _log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING) xbmcgui.Dialog().notification( @@ -3853,6 +3913,11 @@ def _play_episode_url( resolved_link = plugin.resolve_stream_link(link) if not resolved_link: err = _resolveurl_last_error() + if _is_resolveurl_missing_error(err): + _log("ResolveURL fehlt: versuche Auto-Installation.", xbmc.LOGWARNING) + _ensure_resolveurl_installed(force=True, silent=True) + resolved_link = plugin.resolve_stream_link(link) + err = _resolveurl_last_error() if _is_cloudflare_challenge_error(err): _log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING) xbmcgui.Dialog().notification( @@ -3914,6 +3979,7 @@ def run() -> None: action = params.get("action") _log(f"Action: {action}", xbmc.LOGDEBUG) _maybe_run_auto_update_check(action) + _maybe_auto_install_resolveurl(action) if action == "search": _show_search() elif action == "plugin_menu": @@ -3991,6 +4057,8 @@ def run() -> None: _apply_update_channel() elif action == "select_update_version": _show_version_selector() + elif action == "install_resolveurl": + _ensure_resolveurl_installed(force=True, silent=False) elif action == "seasons": _show_seasons(params.get("plugin", ""), params.get("title", ""), params.get("series_url", "")) elif action == "episodes": diff --git a/addon/resolveurl_backend.py b/addon/resolveurl_backend.py index 244c87c..3433316 100644 --- a/addon/resolveurl_backend.py +++ b/addon/resolveurl_backend.py @@ -23,6 +23,7 @@ def resolve(url: str) -> Optional[str]: try: import resolveurl # type: ignore except Exception: + _LAST_RESOLVE_ERROR = "resolveurl missing" return None try: @@ -36,7 +37,10 @@ def resolve(url: str) -> Optional[str]: resolver = getattr(hmf, "resolve", None) if callable(resolver): result = resolver() - return str(result) if result else None + if result: + return str(result) + _LAST_RESOLVE_ERROR = "unresolved" + return None except Exception as exc: _LAST_RESOLVE_ERROR = str(exc or "") pass @@ -45,7 +49,10 @@ def resolve(url: str) -> Optional[str]: resolve_fn = getattr(resolveurl, "resolve", None) if callable(resolve_fn): result = resolve_fn(url) - return str(result) if result else None + if result: + return str(result) + _LAST_RESOLVE_ERROR = "unresolved" + return None except Exception as exc: _LAST_RESOLVE_ERROR = str(exc or "") return None diff --git a/addon/resources/settings.xml b/addon/resources/settings.xml index 75de91d..0d51aaf 100644 --- a/addon/resources/settings.xml +++ b/addon/resources/settings.xml @@ -40,24 +40,18 @@ + + - - + - - - - - - - - + diff --git a/scripts/build_local_kodi_repo.sh b/scripts/build_local_kodi_repo.sh index 861970d..ea6edba 100755 --- a/scripts/build_local_kodi_repo.sh +++ b/scripts/build_local_kodi_repo.sh @@ -118,6 +118,8 @@ md5 = hashlib.md5(addons_xml.read_bytes()).hexdigest() md5_file.write_text(md5, encoding="ascii") PY +python3 "${ROOT_DIR}/scripts/verify_repo_artifacts.py" "${REPO_DIR}" >/dev/null + echo "Repo built:" echo " ${REPO_DIR}/addons.xml" echo " ${REPO_DIR}/addons.xml.md5" diff --git a/scripts/verify_repo_artifacts.py b/scripts/verify_repo_artifacts.py new file mode 100755 index 0000000..57e1a5d --- /dev/null +++ b/scripts/verify_repo_artifacts.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Validate Kodi repository artifacts for ViewIT. + +Usage: + verify_repo_artifacts.py [--expect-branch ] +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +PLUGIN_ID = "plugin.video.viewit" +REPO_ID = "repository.viewit" + + +def _find_addon(root: ET.Element, addon_id: str) -> ET.Element: + if root.tag == "addon" and (root.attrib.get("id") or "") == addon_id: + return root + for addon in root.findall("addon"): + if (addon.attrib.get("id") or "") == addon_id: + return addon + raise ValueError(f"addon {addon_id} not found in addons.xml") + + +def _read_zip_addon_version(zip_path: Path, addon_id: str) -> str: + inner_path = f"{addon_id}/addon.xml" + with zipfile.ZipFile(zip_path, "r") as archive: + try: + data = archive.read(inner_path) + except KeyError as exc: + raise ValueError(f"{zip_path.name}: missing {inner_path}") from exc + root = ET.fromstring(data.decode("utf-8", errors="replace")) + version = (root.attrib.get("version") or "").strip() + if not version: + raise ValueError(f"{zip_path.name}: addon.xml without version") + return version + + +def _check_md5(repo_dir: Path) -> list[str]: + errors: list[str] = [] + addons_xml = repo_dir / "addons.xml" + md5_file = repo_dir / "addons.xml.md5" + if not addons_xml.exists() or not md5_file.exists(): + return errors + expected = md5_file.read_text(encoding="ascii", errors="ignore").strip().lower() + actual = hashlib.md5(addons_xml.read_bytes()).hexdigest() + if expected != actual: + errors.append("addons.xml.md5 does not match addons.xml") + return errors + + +def _check_repo_zip_branch(zip_path: Path, expected_branch: str) -> list[str]: + errors: list[str] = [] + inner_path = f"{REPO_ID}/addon.xml" + with zipfile.ZipFile(zip_path, "r") as archive: + try: + data = archive.read(inner_path) + except KeyError as exc: + raise ValueError(f"{zip_path.name}: missing {inner_path}") from exc + root = ET.fromstring(data.decode("utf-8", errors="replace")) + info = root.find(".//dir/info") + if info is None or not (info.text or "").strip(): + errors.append(f"{zip_path.name}: missing repository info URL") + return errors + info_url = (info.text or "").strip() + marker = f"/branch/{expected_branch}/addons.xml" + if marker not in info_url: + errors.append(f"{zip_path.name}: info URL does not point to branch '{expected_branch}'") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("repo_dir", help="Path to repository root (contains addons.xml)") + parser.add_argument("--expect-branch", default="", help="Expected branch in repository.viewit addon.xml URL") + args = parser.parse_args() + + repo_dir = Path(args.repo_dir).resolve() + addons_xml = repo_dir / "addons.xml" + if not addons_xml.exists(): + print(f"Missing: {addons_xml}", file=sys.stderr) + return 2 + + errors: list[str] = [] + try: + root = ET.parse(addons_xml).getroot() + plugin_node = _find_addon(root, PLUGIN_ID) + repo_node = _find_addon(root, REPO_ID) + except Exception as exc: + print(f"Invalid addons.xml: {exc}", file=sys.stderr) + return 2 + + plugin_version = (plugin_node.attrib.get("version") or "").strip() + repo_version = (repo_node.attrib.get("version") or "").strip() + if not plugin_version: + errors.append("plugin.video.viewit has no version in addons.xml") + if not repo_version: + errors.append("repository.viewit has no version in addons.xml") + + plugin_zip = repo_dir / PLUGIN_ID / f"{PLUGIN_ID}-{plugin_version}.zip" + repo_zip = repo_dir / REPO_ID / f"{REPO_ID}-{repo_version}.zip" + if not plugin_zip.exists(): + errors.append(f"Missing plugin zip: {plugin_zip}") + if not repo_zip.exists(): + errors.append(f"Missing repository zip: {repo_zip}") + + if plugin_zip.exists(): + try: + zip_version = _read_zip_addon_version(plugin_zip, PLUGIN_ID) + if zip_version != plugin_version: + errors.append( + f"{plugin_zip.name}: version mismatch (zip={zip_version}, addons.xml={plugin_version})" + ) + except Exception as exc: + errors.append(str(exc)) + + if repo_zip.exists(): + try: + zip_version = _read_zip_addon_version(repo_zip, REPO_ID) + if zip_version != repo_version: + errors.append(f"{repo_zip.name}: version mismatch (zip={zip_version}, addons.xml={repo_version})") + if args.expect_branch: + errors.extend(_check_repo_zip_branch(repo_zip, args.expect_branch)) + except Exception as exc: + errors.append(str(exc)) + + errors.extend(_check_md5(repo_dir)) + + if errors: + print("Repository validation failed:") + for line in errors: + print(f"- {line}") + return 1 + + print("Repository validation passed.") + print(f"- plugin: {plugin_version}") + print(f"- repository: {repo_version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())