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 importlib.util
import inspect import inspect
import json import json
import io
import os import os
import re import re
import sys import sys
import threading import threading
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path from pathlib import Path
from types import ModuleType 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.error import URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -991,6 +993,19 @@ def _channel_label(channel: int) -> str:
return "Main" 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: def _resolve_update_info_url() -> str:
channel = _selected_update_channel() channel = _selected_update_channel()
if channel == UPDATE_CHANNEL_NIGHTLY: 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) 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: def _sync_update_channel_status_settings() -> None:
channel = _selected_update_channel() channel = _selected_update_channel()
channel_label = _channel_label(channel) channel_label = _channel_label(channel)
@@ -3256,6 +3410,62 @@ def _run_update_check(*, silent: bool = False) -> None:
pass 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: def _maybe_run_auto_update_check(action: str | None) -> None:
action = (action or "").strip() action = (action or "").strip()
# Auto-Check nur beim Root-Menue, nicht in jedem Untermenue. # Auto-Check nur beim Root-Menue, nicht in jedem Untermenue.
@@ -3683,6 +3893,8 @@ def run() -> None:
_run_update_check() _run_update_check()
elif action == "apply_update_channel": elif action == "apply_update_channel":
_apply_update_channel() _apply_update_channel()
elif action == "select_update_version":
_show_version_selector()
elif action == "seasons": elif action == "seasons":
_show_seasons(params.get("plugin", ""), params.get("title", ""), params.get("series_url", "")) _show_seasons(params.get("plugin", ""), params.get("title", ""), params.get("series_url", ""))
elif action == "episodes": elif action == "episodes":

View File

@@ -40,6 +40,7 @@
<setting id="apply_update_channel" type="action" label="Update-Kanal jetzt anwenden" action="RunPlugin(plugin://plugin.video.viewit/?action=apply_update_channel)" option="close" /> <setting id="apply_update_channel" type="action" label="Update-Kanal jetzt anwenden" action="RunPlugin(plugin://plugin.video.viewit/?action=apply_update_channel)" option="close" />
<setting id="auto_update_enabled" type="bool" label="Automatische Updates (beim Start pruefen)" default="false" /> <setting id="auto_update_enabled" type="bool" label="Automatische Updates (beim Start pruefen)" default="false" />
<setting id="run_update_check" type="action" label="Jetzt nach Updates suchen" action="RunPlugin(plugin://plugin.video.viewit/?action=check_updates)" option="close" /> <setting id="run_update_check" type="action" label="Jetzt nach Updates suchen" action="RunPlugin(plugin://plugin.video.viewit/?action=check_updates)" option="close" />
<setting id="select_update_version" type="action" label="Version waehlen und installieren" action="RunPlugin(plugin://plugin.video.viewit/?action=select_update_version)" option="close" />
<setting id="update_installed_version" type="text" label="Installierte Version" default="-" enable="false" /> <setting id="update_installed_version" type="text" label="Installierte Version" default="-" enable="false" />
<setting id="update_available_selected" type="text" label="Verfuegbar (gewaehlter Kanal)" default="-" enable="false" /> <setting id="update_available_selected" type="text" label="Verfuegbar (gewaehlter Kanal)" default="-" enable="false" />
<setting id="update_available_main" type="text" label="Verfuegbar Main" default="-" enable="false" /> <setting id="update_available_main" type="text" label="Verfuegbar Main" default="-" enable="false" />