release: 0.1.61

This commit is contained in:
2026-02-23 21:56:58 +01:00
parent 22b9ae9c31
commit ae01c79555
9 changed files with 638 additions and 177 deletions

View File

@@ -14,15 +14,19 @@ 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
def _ensure_windows_selector_policy() -> None:
@@ -131,6 +135,7 @@ _TMDB_LOCK = threading.RLock()
WATCHED_THRESHOLD = 0.9
POPULAR_MENU_LABEL = "Haeufig gesehen"
LATEST_MENU_LABEL = "Neuste Titel"
LIST_PAGE_SIZE = 20
atexit.register(close_all_sessions)
@@ -305,10 +310,6 @@ def _playstate_key(*, plugin_name: str, title: str, season: str, episode: str) -
return f"{plugin_name}\t{title}\t{season}\t{episode}"
def _playstate_path() -> str:
return _get_log_path("playstate.json")
def _load_playstate() -> dict[str, dict[str, object]]:
return {}
@@ -973,6 +974,8 @@ UPDATE_CHANNEL_MAIN = 0
UPDATE_CHANNEL_NIGHTLY = 1
UPDATE_CHANNEL_CUSTOM = 2
AUTO_UPDATE_INTERVAL_SEC = 6 * 60 * 60
UPDATE_HTTP_TIMEOUT_SEC = 8
UPDATE_ADDON_ID = "plugin.video.viewit"
def _selected_update_channel() -> int:
@@ -982,6 +985,43 @@ def _selected_update_channel() -> int:
return channel
def _channel_label(channel: int) -> str:
if channel == UPDATE_CHANNEL_NIGHTLY:
return "Nightly"
if channel == UPDATE_CHANNEL_CUSTOM:
return "Custom"
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 _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 _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)]
return list(versions)
def _resolve_update_info_url() -> str:
channel = _selected_update_channel()
if channel == UPDATE_CHANNEL_NIGHTLY:
@@ -996,6 +1036,234 @@ def _resolve_update_info_url() -> str:
return info_url
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 _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 _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_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}", 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 _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"
# Prefer Kodi's own installer to get proper install flow and dependency handling.
builtin = getattr(xbmc, "executebuiltin", 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}", xbmc.LOGWARNING)
return _install_addon_version_manual(info_url, version)
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)
_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)
def _repo_addon_xml_path() -> str:
if xbmcvfs is None:
return ""
@@ -1038,15 +1306,34 @@ def _settings_key_for_plugin(name: str) -> str:
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"
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 _sync_update_version_settings() -> None:
addon = _get_addon()
addon_version = "0.0.0"
if addon is not None:
try:
addon_version = str(addon.getAddonInfo("version") or "0.0.0")
except Exception:
addon_version = "0.0.0"
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_version_addon", addon_version)
_set_setting_string("update_installed_version", addon_version)
versions = {
"update_version_serienstream": "-",
@@ -1063,6 +1350,8 @@ def _sync_update_version_settings() -> None:
for key, value in versions.items():
_set_setting_string(key, value)
_sync_update_channel_status_settings()
def _show_root_menu() -> None:
handle = _get_handle()
@@ -2574,7 +2863,7 @@ def _plugins_with_popular() -> list[tuple[str, BasisPlugin, str]]:
def _show_popular(plugin_name: str | None = None, page: int = 1) -> None:
handle = _get_handle()
page_size = 10
page_size = LIST_PAGE_SIZE
page = max(1, int(page or 1))
if plugin_name:
@@ -2701,7 +2990,7 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None:
def _show_new_titles(plugin_name: str, page: int = 1, *, action_name: str = "new_titles") -> None:
handle = _get_handle()
page_size = 10
page_size = LIST_PAGE_SIZE
page = max(1, int(page or 1))
plugin_name = (plugin_name or "").strip()
@@ -2949,7 +3238,7 @@ def _show_latest_titles(plugin_name: str, page: int = 1) -> None:
def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page: int = 1) -> None:
handle = _get_handle()
page_size = 10
page_size = LIST_PAGE_SIZE
page = max(1, int(page or 1))
plugin = _discover_plugins().get(plugin_name)
if plugin is None:
@@ -3132,19 +3421,77 @@ def _open_settings() -> None:
addon.openSettings()
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)
xbmcgui.Dialog().notification(
"Updates",
"Kanal gespeichert, aber repository.viewit nicht gefunden.",
warning_icon,
5000,
)
elif target_version == "-":
xbmcgui.Dialog().notification(
"Updates",
"Kanal angewendet, aber keine Version im Kanal gefunden.",
xbmcgui.NOTIFICATION_ERROR,
5000,
)
elif not install_result:
xbmcgui.Dialog().notification(
"Updates",
f"Kanal angewendet, Installation von {target_version} fehlgeschlagen.",
xbmcgui.NOTIFICATION_ERROR,
5000,
)
elif target_version == installed_version:
xbmcgui.Dialog().notification(
"Updates",
f"Kanal angewendet: {_channel_label(_selected_update_channel())} ({target_version} bereits installiert)",
xbmcgui.NOTIFICATION_INFO,
4500,
)
else:
xbmcgui.Dialog().notification(
"Updates",
f"Kanal angewendet: {_channel_label(_selected_update_channel())} -> {target_version} installiert",
xbmcgui.NOTIFICATION_INFO,
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:
info_url = _resolve_update_info_url()
_sync_update_version_settings()
_update_repository_source(info_url)
builtin = getattr(xbmc, "executebuiltin", None)
if callable(builtin):
builtin("UpdateAddonRepos")
builtin("UpdateLocalAddons")
if not silent:
_apply_update_channel(silent=True)
if not silent:
builtin = getattr(xbmc, "executebuiltin", None)
if callable(builtin):
builtin("ActivateWindow(addonbrowser,addons://updates/)")
if not silent:
xbmcgui.Dialog().notification("Updates", "Update-Check gestartet.", xbmcgui.NOTIFICATION_INFO, 4000)
@@ -3157,6 +3504,59 @@ 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 = _filter_versions_for_channel(channel, _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
confirm_choice = xbmcgui.Dialog().select(
"Version installieren",
[
f"Installieren: {version}",
"Abbrechen",
],
)
if confirm_choice != 0:
return
xbmcgui.Dialog().notification("Updates", f"Installation gestartet: {version}", xbmcgui.NOTIFICATION_INFO, 2500)
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.
@@ -3182,23 +3582,28 @@ def _extract_first_int(value: str) -> int | None:
return None
def _duration_label(duration_seconds: int) -> str:
try:
duration_seconds = int(duration_seconds or 0)
except Exception:
duration_seconds = 0
if duration_seconds <= 0:
return ""
total_minutes = max(0, duration_seconds // 60)
hours = max(0, total_minutes // 60)
minutes = max(0, total_minutes % 60)
return f"{hours:02d}:{minutes:02d} Laufzeit"
def _label_with_duration(label: str, info_labels: dict[str, str] | None) -> str:
return label
def _resolveurl_last_error() -> str:
try:
from resolveurl_backend import get_last_error # type: ignore
except Exception:
return ""
try:
return str(get_last_error() or "")
except Exception:
return ""
def _is_cloudflare_challenge_error(message: str) -> bool:
text = str(message or "").casefold()
if not text:
return False
return "cloudflare" in text or "challenge" in text or "attention required" in text
def _play_final_link(
link: str,
*,
@@ -3242,10 +3647,6 @@ def _play_final_link(
player.play(item=link, listitem=list_item)
def _track_playback_and_update_state(key: str) -> None:
return
def _track_playback_and_update_state_async(key: str) -> None:
# Eigenes Resume/Watched ist deaktiviert; Kodi verwaltet das selbst.
return
@@ -3334,8 +3735,30 @@ def _play_episode(
xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
return
_log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
final_link = plugin.resolve_stream_link(link) or link
resolved_link = plugin.resolve_stream_link(link)
if not resolved_link:
err = _resolveurl_last_error()
if _is_cloudflare_challenge_error(err):
_log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING)
xbmcgui.Dialog().notification(
"Wiedergabe",
"Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.",
xbmcgui.NOTIFICATION_INFO,
4500,
)
return
final_link = resolved_link or link
final_link = normalize_resolved_stream_url(final_link, source_url=link)
err = _resolveurl_last_error()
if _is_cloudflare_challenge_error(err) and final_link.strip() == link.strip():
_log(f"ResolveURL Cloudflare-Challenge (unresolved): {err}", xbmc.LOGWARNING)
xbmcgui.Dialog().notification(
"Wiedergabe",
"Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.",
xbmcgui.NOTIFICATION_INFO,
4500,
)
return
finally:
if restore_hosters is not None and callable(preferred_setter):
preferred_setter(restore_hosters)
@@ -3422,8 +3845,30 @@ def _play_episode_url(
xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
return
_log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
final_link = plugin.resolve_stream_link(link) or link
resolved_link = plugin.resolve_stream_link(link)
if not resolved_link:
err = _resolveurl_last_error()
if _is_cloudflare_challenge_error(err):
_log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING)
xbmcgui.Dialog().notification(
"Wiedergabe",
"Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.",
xbmcgui.NOTIFICATION_INFO,
4500,
)
return
final_link = resolved_link or link
final_link = normalize_resolved_stream_url(final_link, source_url=link)
err = _resolveurl_last_error()
if _is_cloudflare_challenge_error(err) and final_link.strip() == link.strip():
_log(f"ResolveURL Cloudflare-Challenge (unresolved): {err}", xbmc.LOGWARNING)
xbmcgui.Dialog().notification(
"Wiedergabe",
"Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.",
xbmcgui.NOTIFICATION_INFO,
4500,
)
return
finally:
if restore_hosters is not None and callable(preferred_setter):
preferred_setter(restore_hosters)
@@ -3537,6 +3982,10 @@ def run() -> None:
_open_settings()
elif action == "check_updates":
_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":