diff --git a/addon/default.py b/addon/default.py
index ea45fce..1b27107 100644
--- a/addon/default.py
+++ b/addon/default.py
@@ -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":
diff --git a/addon/resources/settings.xml b/addon/resources/settings.xml
index 876fd6c..6a859ed 100644
--- a/addon/resources/settings.xml
+++ b/addon/resources/settings.xml
@@ -40,6 +40,7 @@
+