dev: harden resolver bootstrap and simplify update settings

This commit is contained in:
2026-02-24 16:18:44 +01:00
parent 99b67a24f8
commit 16e4b5f261
8 changed files with 298 additions and 71 deletions

View File

@@ -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).

View File

@@ -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://<host>:<port>/repo ./scripts/build_local_kodi_repo.sh`

View File

@@ -1,10 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<addon id="plugin.video.viewit" name="ViewIt" version="0.1.62-dev" provider-name="ViewIt">
<addon id="plugin.video.viewit" name="ViewIt" version="0.1.63-dev" provider-name="ViewIt">
<requires>
<import addon="xbmc.python" version="3.0.0" />
<import addon="script.module.requests" />
<import addon="script.module.beautifulsoup4" />
<import addon="script.module.resolveurl" />
</requires>
<extension point="xbmc.python.pluginsource" library="default.py">
<provides>video</provides>

View File

@@ -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":

View File

@@ -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

View File

@@ -40,24 +40,18 @@
<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="select_update_version" type="action" label="Version waehlen und installieren" action="RunPlugin(plugin://plugin.video.viewit/?action=select_update_version)" option="close" />
<setting id="install_resolveurl" type="action" label="ResolveURL installieren/reparieren" action="RunPlugin(plugin://plugin.video.viewit/?action=install_resolveurl)" option="close" />
<setting id="resolveurl_auto_install" type="bool" label="ResolveURL automatisch installieren (beim Start pruefen)" default="true" />
<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_main" type="text" label="Verfuegbar Main" default="-" enable="false" />
<setting id="update_available_nightly" type="text" label="Verfuegbar Nightly" default="-" enable="false" />
<setting id="resolveurl_status" type="text" label="ResolveURL Status" default="-" enable="false" />
<setting id="update_active_channel" type="text" label="Aktiver Kanal" default="-" enable="false" />
<setting id="update_active_repo_url" type="text" label="Aktive Repo URL" default="-" enable="false" />
<setting id="update_info" type="text" label="Updates laufen ueber den normalen Kodi-Update-Mechanismus." default="" enable="false" />
<setting id="update_repo_url_main" type="text" label="Main URL (addons.xml)" default="https://gitea.it-drui.de/viewit/ViewIT-Kodi-Repo/raw/branch/main/addons.xml" />
<setting id="update_repo_url_nightly" type="text" label="Nightly URL (addons.xml)" default="https://gitea.it-drui.de/viewit/ViewIT-Kodi-Repo/raw/branch/nightly/addons.xml" />
<setting id="update_repo_url" type="text" label="Custom URL (addons.xml)" default="https://gitea.it-drui.de/viewit/ViewIT-Kodi-Repo/raw/branch/nightly/addons.xml" />
<setting id="auto_update_last_ts" type="text" label="Auto-Update letzte Pruefung (intern)" default="0" visible="false" />
<setting id="update_version_addon" type="text" label="ViewIT Version" default="-" visible="false" />
<setting id="update_version_serienstream" type="text" label="SerienStream Version" default="-" visible="false" />
<setting id="update_version_aniworld" type="text" label="AniWorld Version" default="-" visible="false" />
<setting id="update_version_einschalten" type="text" label="Einschalten Version" default="-" visible="false" />
<setting id="update_version_topstreamfilm" type="text" label="TopStream Version" default="-" visible="false" />
<setting id="update_version_filmpalast" type="text" label="Filmpalast Version" default="-" visible="false" />
<setting id="update_version_doku_streams" type="text" label="Doku-Streams Version" default="-" visible="false" />
<setting id="resolveurl_last_ts" type="text" label="ResolveURL letzte Pruefung (intern)" default="0" visible="false" />
</category>
<category label="Debug Global">

View File

@@ -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"

147
scripts/verify_repo_artifacts.py Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Validate Kodi repository artifacts for ViewIT.
Usage:
verify_repo_artifacts.py <repo_dir> [--expect-branch <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())