#!/usr/bin/env python3 """Update- und Versionsverwaltung fuer ViewIT. Dieses Modul kuemmert sich um: - Update-Kanaele (Main, Nightly, Dev, Custom) - Versions-Abfrage und -Installation aus Repositories - Changelog-Abruf - Repository-Quellen-Verwaltung - ResolveURL Auto-Installation """ from __future__ import annotations import io import json import os import re import time import xml.etree.ElementTree as ET import zipfile from urllib.error import URLError from urllib.request import Request, urlopen try: # pragma: no cover - Kodi runtime import xbmc # type: ignore[import-not-found] import xbmcaddon # type: ignore[import-not-found] import xbmcgui # type: ignore[import-not-found] import xbmcvfs # type: ignore[import-not-found] except ImportError: # pragma: no cover - allow importing outside Kodi xbmc = None xbmcaddon = None xbmcgui = None xbmcvfs = None from plugin_helpers import show_error, show_notification # --------------------------------------------------------------------------- # Konstanten # --------------------------------------------------------------------------- UPDATE_CHANNEL_MAIN = 0 UPDATE_CHANNEL_NIGHTLY = 1 UPDATE_CHANNEL_CUSTOM = 2 UPDATE_CHANNEL_DEV = 3 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 # --------------------------------------------------------------------------- # Hilfsfunktionen (Settings-Zugriff) # --------------------------------------------------------------------------- # Diese Callbacks werden von default.py einmal gesetzt, damit updater.py # keine zirkulaeren Abhaengigkeiten hat. _get_setting_string = None _get_setting_bool = None _get_setting_int = None _set_setting_string = None _get_addon = None _log_fn = None def init( *, get_setting_string, get_setting_bool, get_setting_int, set_setting_string, get_addon, log_fn, ) -> None: """Initialisiert Callbacks fuer Settings-Zugriff.""" global _get_setting_string, _get_setting_bool, _get_setting_int global _set_setting_string, _get_addon, _log_fn _get_setting_string = get_setting_string _get_setting_bool = get_setting_bool _get_setting_int = get_setting_int _set_setting_string = set_setting_string _get_addon = get_addon _log_fn = log_fn def _log(message: str, level: int = 1) -> None: if _log_fn is not None: _log_fn(message, level) # --------------------------------------------------------------------------- # URL-Normalisierung # --------------------------------------------------------------------------- def normalize_update_info_url(raw: str) -> str: value = str(raw or "").strip() default = "http://127.0.0.1:8080/repo/addons.xml" if not value: return default if value.endswith("/addons.xml"): return value return value.rstrip("/") + "/addons.xml" # --------------------------------------------------------------------------- # Update-Kanaele # --------------------------------------------------------------------------- def selected_update_channel() -> int: channel = _get_setting_int("update_channel", default=UPDATE_CHANNEL_MAIN) if channel not in {UPDATE_CHANNEL_MAIN, UPDATE_CHANNEL_NIGHTLY, UPDATE_CHANNEL_CUSTOM, UPDATE_CHANNEL_DEV}: return UPDATE_CHANNEL_MAIN return channel def channel_label(channel: int) -> str: if channel == UPDATE_CHANNEL_NIGHTLY: return "Nightly" if channel == UPDATE_CHANNEL_DEV: return "Dev" if channel == UPDATE_CHANNEL_CUSTOM: return "Custom" return "Main" # --------------------------------------------------------------------------- # Versionierung # --------------------------------------------------------------------------- def version_sort_key(version: str) -> tuple[int, ...]: base = str(version or "").split("-", 1)[0] parts = [] for chunk in base.split("."): try: parts.append(int(chunk)) except Exception: parts.append(0) while len(parts) < 4: parts.append(0) return tuple(parts[:4]) def is_stable_version(version: str) -> bool: return bool(re.match(r"^\d+\.\d+\.\d+$", str(version or "").strip())) def is_nightly_version(version: str) -> bool: return bool(re.match(r"^\d+\.\d+\.\d+-nightly$", str(version or "").strip())) def is_dev_version(version: str) -> bool: return bool(re.match(r"^\d+\.\d+\.\d+-dev$", str(version or "").strip())) def filter_versions_for_channel(channel: int, versions: list[str]) -> list[str]: if channel == UPDATE_CHANNEL_MAIN: return [v for v in versions if is_stable_version(v)] if channel == UPDATE_CHANNEL_NIGHTLY: return [v for v in versions if is_nightly_version(v)] if channel == UPDATE_CHANNEL_DEV: return [v for v in versions if is_dev_version(v)] return list(versions) # --------------------------------------------------------------------------- # HTTP-Helfer # --------------------------------------------------------------------------- def read_text_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> str: request = Request(url, headers={"User-Agent": "ViewIT/1.0"}) response = None try: response = urlopen(request, timeout=timeout) data = response.read() finally: if response is not None: try: response.close() except Exception: pass return data.decode("utf-8", errors="replace") def read_binary_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> bytes: request = Request(url, headers={"User-Agent": "ViewIT/1.0"}) response = None try: response = urlopen(request, timeout=timeout) return response.read() finally: if response is not None: try: response.close() except Exception: pass # --------------------------------------------------------------------------- # Repo-Abfragen # --------------------------------------------------------------------------- def extract_repo_addon_version(xml_text: str, addon_id: str = UPDATE_ADDON_ID) -> str: try: root = ET.fromstring(xml_text) except Exception: return "-" if root.tag == "addon": return str(root.attrib.get("version") or "-") for node in root.findall("addon"): if str(node.attrib.get("id") or "").strip() == addon_id: version = str(node.attrib.get("version") or "").strip() return version or "-" return "-" def fetch_repo_addon_version(info_url: str) -> str: url = normalize_update_info_url(info_url) try: xml_text = read_text_url(url) except URLError: return "-" except Exception: return "-" return extract_repo_addon_version(xml_text) def _extract_repo_identity(info_url: str) -> tuple[str, str, str, str] | None: from urllib.parse import urlparse parsed = urlparse(str(info_url or "").strip()) parts = [part for part in parsed.path.split("/") if part] try: raw_idx = parts.index("raw") except ValueError: return None if raw_idx < 2 or (raw_idx + 2) >= len(parts): return None if parts[raw_idx + 1] != "branch": return None owner = parts[raw_idx - 2] repo = parts[raw_idx - 1] branch = parts[raw_idx + 2] scheme = parsed.scheme or "https" host = parsed.netloc if not owner or not repo or not branch or not host: return None return scheme, host, owner, repo + "|" + branch def fetch_repo_versions(info_url: str) -> list[str]: identity = _extract_repo_identity(info_url) if identity is None: one = fetch_repo_addon_version(info_url) return [one] if one != "-" else [] scheme, host, owner, repo_branch = identity repo, branch = repo_branch.split("|", 1) api_url = f"{scheme}://{host}/api/v1/repos/{owner}/{repo}/contents/{UPDATE_ADDON_ID}?ref={branch}" try: payload = read_text_url(api_url) data = json.loads(payload) except Exception: one = fetch_repo_addon_version(info_url) return [one] if one != "-" else [] versions: list[str] = [] if isinstance(data, list): for entry in data: if not isinstance(entry, dict): continue name = str(entry.get("name") or "") match = re.match(rf"^{re.escape(UPDATE_ADDON_ID)}-(.+)\.zip$", name) if not match: continue version = match.group(1).strip() if version: versions.append(version) unique = sorted(set(versions), key=version_sort_key, reverse=True) return unique # --------------------------------------------------------------------------- # Changelog # --------------------------------------------------------------------------- def extract_changelog_section(changelog_text: str, version: str) -> str: lines = changelog_text.splitlines() wanted = (version or "").strip() if not wanted: return "\n".join(lines[:120]).strip() start = -1 for idx, line in enumerate(lines): if line.startswith("## ") and wanted in line: start = idx break if start < 0: return f"Kein Changelog-Abschnitt fuer Version {wanted} gefunden." end = len(lines) for idx in range(start + 1, len(lines)): if lines[idx].startswith("## "): end = idx break return "\n".join(lines[start:end]).strip() def fetch_changelog_for_channel(channel: int, version: str) -> str: version_text = str(version or "").strip().casefold() if version_text.endswith("-dev"): url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/dev/CHANGELOG-DEV.md" elif version_text.endswith("-nightly"): url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/nightly/CHANGELOG-NIGHTLY.md" elif channel == UPDATE_CHANNEL_DEV: url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/dev/CHANGELOG-DEV.md" elif channel == UPDATE_CHANNEL_MAIN: url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/main/CHANGELOG.md" else: url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/nightly/CHANGELOG-NIGHTLY.md" try: text = read_text_url(url) except Exception: return "Changelog konnte nicht geladen werden." return extract_changelog_section(text, version) # --------------------------------------------------------------------------- # Installation # --------------------------------------------------------------------------- def install_addon_version_manual(info_url: str, version: str) -> bool: base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/") zip_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip" try: zip_bytes = read_binary_url(zip_url) except Exception as exc: _log(f"Download fehlgeschlagen ({zip_url}): {exc}", 2) return False if xbmcvfs is None: return False addons_root = xbmcvfs.translatePath("special://home/addons") addons_root_real = os.path.realpath(addons_root) try: with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: for member in archive.infolist(): name = str(member.filename or "") if not name or name.endswith("/"): continue target = os.path.realpath(os.path.join(addons_root, name)) if not target.startswith(addons_root_real + os.sep): _log(f"Sicherheitswarnung: Verdaechtiger ZIP-Eintrag abgelehnt: {name!r}", 2) return False os.makedirs(os.path.dirname(target), exist_ok=True) with archive.open(member, "r") as src, open(target, "wb") as dst: dst.write(src.read()) except Exception as exc: _log(f"Entpacken fehlgeschlagen: {exc}", 2) return False builtin = getattr(xbmc, "executebuiltin", None) if xbmc else None if callable(builtin): builtin("UpdateLocalAddons") return True def install_addon_version(info_url: str, version: str) -> bool: base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/") zip_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip" builtin = getattr(xbmc, "executebuiltin", None) if xbmc else None if callable(builtin): try: before = installed_addon_version_from_disk() builtin(f"InstallAddon({zip_url})") for _ in range(20): time.sleep(1) current = installed_addon_version_from_disk() if current == version: return True if before == version: return True except Exception as exc: _log(f"InstallAddon fehlgeschlagen, fallback aktiv: {exc}", 2) return install_addon_version_manual(info_url, version) # --------------------------------------------------------------------------- # Installierte Version / Addon-Pruefung # --------------------------------------------------------------------------- def installed_addon_version_from_disk() -> str: if xbmcvfs is None: return "0.0.0" try: addon_xml = xbmcvfs.translatePath(f"special://home/addons/{UPDATE_ADDON_ID}/addon.xml") except Exception: return "0.0.0" if not addon_xml or not os.path.exists(addon_xml): return "0.0.0" try: root = ET.parse(addon_xml).getroot() version = str(root.attrib.get("version") or "").strip() return version or "0.0.0" except Exception: 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 xbmc else 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)) # --------------------------------------------------------------------------- # Repository-Quellen-Verwaltung # --------------------------------------------------------------------------- def repo_addon_xml_path() -> str: if xbmcvfs is None: return "" try: return xbmcvfs.translatePath("special://home/addons/repository.viewit/addon.xml") except Exception: return "" def update_repository_source(info_url: str) -> bool: path = repo_addon_xml_path() if not path: return False if not os.path.exists(path): return False try: tree = ET.parse(path) root = tree.getroot() dir_node = root.find(".//dir") if dir_node is None: return False info = dir_node.find("info") checksum = dir_node.find("checksum") datadir = dir_node.find("datadir") if info is None or checksum is None or datadir is None: return False base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/") info.text = info_url checksum.text = f"{base}/addons.xml.md5" datadir.text = f"{base}/" tree.write(path, encoding="utf-8", xml_declaration=True) return True except Exception as exc: _log(f"Repository-URL konnte nicht gesetzt werden: {exc}", 2) return False # --------------------------------------------------------------------------- # ResolveURL # --------------------------------------------------------------------------- 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 xbmc else 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}", 2) 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 and xbmcgui is not None: 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) # --------------------------------------------------------------------------- # Update-Kanal anwenden / Sync # --------------------------------------------------------------------------- def resolve_update_info_url() -> str: channel = selected_update_channel() if channel == UPDATE_CHANNEL_NIGHTLY: raw = _get_setting_string("update_repo_url_nightly") elif channel == UPDATE_CHANNEL_DEV: raw = _get_setting_string("update_repo_url_dev") elif channel == UPDATE_CHANNEL_CUSTOM: raw = _get_setting_string("update_repo_url") else: raw = _get_setting_string("update_repo_url_main") return normalize_update_info_url(raw) def sync_update_channel_status_settings() -> None: channel = selected_update_channel() selected_info_url = resolve_update_info_url() 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_selected", available_selected) def sync_update_version_settings() -> None: addon_version = installed_addon_version_from_disk() if addon_version == "0.0.0": addon = _get_addon() if addon is not None: try: addon_version = str(addon.getAddonInfo("version") or "0.0.0") except Exception: addon_version = "0.0.0" _set_setting_string("update_installed_version", addon_version) sync_resolveurl_status_setting() sync_update_channel_status_settings() def apply_update_channel(*, silent: bool = False) -> bool: if xbmc is None: # pragma: no cover - outside Kodi return False info_url = resolve_update_info_url() channel = selected_update_channel() sync_update_version_settings() applied = update_repository_source(info_url) installed_version = _get_setting_string("update_installed_version").strip() or "0.0.0" versions = filter_versions_for_channel(channel, fetch_repo_versions(info_url)) target_version = versions[0] if versions else "-" install_result = False if target_version != "-" and target_version != installed_version: install_result = install_addon_version(info_url, target_version) elif target_version == installed_version: install_result = True builtin = getattr(xbmc, "executebuiltin", None) if callable(builtin): builtin("UpdateAddonRepos") builtin("UpdateLocalAddons") if not silent: if not applied: warning_icon = getattr(xbmcgui, "NOTIFICATION_WARNING", xbmcgui.NOTIFICATION_INFO) show_notification( "Updates", "Kanal gespeichert, aber repository.viewit nicht gefunden.", icon=warning_icon, milliseconds=5000, ) elif target_version == "-": show_error("Updates", "Kanal angewendet, aber keine Version im Kanal gefunden.", milliseconds=5000) elif not install_result: show_error( "Updates", f"Kanal angewendet, Installation von {target_version} fehlgeschlagen.", milliseconds=5000, ) elif target_version == installed_version: show_notification( "Updates", f"Kanal angewendet: {channel_label(selected_update_channel())} ({target_version} bereits installiert)", milliseconds=4500, ) else: show_notification( "Updates", f"Kanal angewendet: {channel_label(selected_update_channel())} -> {target_version} installiert", milliseconds=5000, ) sync_update_version_settings() return applied and install_result def run_update_check(*, silent: bool = False) -> None: """Stoesst Kodi-Repo- und Addon-Updates an.""" if xbmc is None: # pragma: no cover - outside Kodi return try: apply_update_channel(silent=True) if not silent: builtin = getattr(xbmc, "executebuiltin", None) if callable(builtin): builtin("ActivateWindow(addonbrowser,addons://updates/)") if not silent: show_notification("Updates", "Update-Check gestartet.", milliseconds=4000) except Exception as exc: _log(f"Update-Pruefung fehlgeschlagen: {exc}", 2) if not silent: show_error("Updates", "Update-Check fehlgeschlagen.", milliseconds=4000) def show_version_selector() -> None: if xbmc is None: # pragma: no cover - outside Kodi return info_url = resolve_update_info_url() channel = selected_update_channel() sync_update_version_settings() versions = filter_versions_for_channel(channel, fetch_repo_versions(info_url)) if not versions: show_error("Updates", "Keine Versionen im Repo gefunden.", milliseconds=4000) return installed = _get_setting_string("update_installed_version").strip() or "-" options = [] for version in versions: label = version if version == installed: label = f"{version} (installiert)" options.append(label) selected = xbmcgui.Dialog().select("Version waehlen", options) if selected < 0 or selected >= len(versions): return version = versions[selected] changelog = fetch_changelog_for_channel(channel, version) viewer = getattr(xbmcgui.Dialog(), "textviewer", None) if callable(viewer): try: viewer(f"Changelog {version}", changelog) except Exception: pass action = xbmcgui.Dialog().select( f"Version {version} installieren?", ["Update installieren", "Abbrechen"], ) if action != 0: return show_notification("Updates", f"Installation gestartet: {version}", milliseconds=2500) ok = install_addon_version(info_url, version) if ok: sync_update_version_settings() show_notification("Updates", f"Version {version} installiert.", milliseconds=4000) else: show_error("Updates", f"Installation von {version} fehlgeschlagen.", milliseconds=4500) def maybe_run_auto_update_check(action: str | None) -> None: action = (action or "").strip() if action: return if not _get_setting_bool("auto_update_enabled", default=False): return now = int(time.time()) last = _get_setting_int("auto_update_last_ts", default=0) if last > 0 and (now - last) < AUTO_UPDATE_INTERVAL_SEC: return _set_setting_string("auto_update_last_ts", str(now)) run_update_check(silent=True)