updates: add version picker with changelog and install/cancel flow

This commit is contained in:
2026-02-23 20:44:33 +01:00
parent 59728875e9
commit d876d5b84c
2 changed files with 214 additions and 1 deletions

View File

@@ -14,15 +14,17 @@ from datetime import datetime
import importlib.util
import inspect
import json
import io
import os
import re
import sys
import threading
import time
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
from types import ModuleType
from urllib.parse import parse_qs, urlencode
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.error import URLError
from urllib.request import Request, urlopen
@@ -991,6 +993,19 @@ def _channel_label(channel: int) -> str:
return "Main"
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 _resolve_update_info_url() -> str:
channel = _selected_update_channel()
if channel == UPDATE_CHANNEL_NIGHTLY:
@@ -1047,6 +1062,145 @@ def _fetch_repo_addon_version(info_url: str) -> str:
return _extract_repo_addon_version(xml_text)
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
def _extract_repo_identity(info_url: str) -> tuple[str, str, str, str] | None:
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
# expected: /{owner}/{repo}/raw/branch/{branch}/addons.xml
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
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:
if 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)
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"
try:
zip_bytes = _read_binary_url(zip_url)
except Exception as exc:
_log(f"Download fehlgeschlagen ({zip_url}): {exc}", xbmc.LOGWARNING)
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):
continue
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}", xbmc.LOGWARNING)
return False
builtin = getattr(xbmc, "executebuiltin", None)
if callable(builtin):
builtin("UpdateLocalAddons")
return True
def _sync_update_channel_status_settings() -> None:
channel = _selected_update_channel()
channel_label = _channel_label(channel)
@@ -3256,6 +3410,62 @@ def _run_update_check(*, silent: bool = False) -> None:
pass
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 = _fetch_repo_versions(info_url)
if not versions:
xbmcgui.Dialog().notification("Updates", "Keine Versionen im Repo gefunden.", xbmcgui.NOTIFICATION_ERROR, 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
dialog = xbmcgui.Dialog()
try:
confirm = dialog.yesno(
"Version installieren",
f"Version: {version}",
"Installation jetzt starten?",
yeslabel="Installieren",
nolabel="Abbrechen",
)
except TypeError:
confirm = dialog.yesno("Version installieren", f"Version: {version}", "Installation jetzt starten?")
if not confirm:
return
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)
else:
xbmcgui.Dialog().notification("Updates", f"Installation von {version} fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4500)
def _maybe_run_auto_update_check(action: str | None) -> None:
action = (action or "").strip()
# Auto-Check nur beim Root-Menue, nicht in jedem Untermenue.
@@ -3683,6 +3893,8 @@ def run() -> None:
_run_update_check()
elif action == "apply_update_channel":
_apply_update_channel()
elif action == "select_update_version":
_show_version_selector()
elif action == "seasons":
_show_seasons(params.get("plugin", ""), params.get("title", ""), params.get("series_url", ""))
elif action == "episodes":