dev: umfangreiches Refactoring, Trakt-Integration und Code-Review-Fixes (0.1.69-dev)
Core & Architektur: - Neues Verzeichnis addon/core/ mit router.py, trakt.py, metadata.py, gui.py, playstate.py, plugin_manager.py, updater.py - Tests-Verzeichnis hinzugefügt (24 Tests, pytest + Coverage) Trakt-Integration: - OAuth Device Flow, Scrobbling, Watchlist, History, Calendar - Upcoming Episodes, Weiterschauen (Continue Watching) - Watched-Status in Episodenlisten - _trakt_find_in_plugins() mit 5-Min-Cache Serienstream-Suche: - API-Ergebnisse werden immer mit Katalog-Cache ergänzt (serverseitiges 10-Treffer-Limit) - Katalog-Cache wird beim Addon-Start im Daemon-Thread vorgewärmt - Notification nach Cache-Load via xbmc.executebuiltin() (thread-sicher) Bugfixes (Code-Review): - Race Condition auf _TRAKT_WATCHED_CACHE: _TRAKT_WATCHED_CACHE_LOCK hinzugefügt - GUI-Dialog aus Daemon-Thread: xbmcgui -> xbmc.executebuiltin() - ValueError in Trakt-Watchlist-Routen abgesichert - Token expires_at==0 Check korrigiert - get_setting_bool() Kontrollfluss in gui.py bereinigt - topstreamfilm_plugin: try-finally um xbmcvfs.File.close() Cleanup: - default.py.bak und refactor_router.py entfernt - .gitignore: /tests/ Eintrag entfernt - Type-Hints vereinheitlicht (Dict/List/Tuple -> dict/list/tuple)
This commit is contained in:
2
addon/core/__init__.py
Normal file
2
addon/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from __future__ import annotations
|
||||
# ViewIT core package
|
||||
341
addon/core/gui.py
Normal file
341
addon/core/gui.py
Normal file
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import re
|
||||
import contextlib
|
||||
from urllib.parse import urlencode
|
||||
from typing import Any, Generator, Optional, Callable
|
||||
from contextlib import contextmanager
|
||||
|
||||
try:
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
import xbmcgui
|
||||
import xbmcplugin
|
||||
except ImportError:
|
||||
xbmc = None
|
||||
xbmcaddon = None
|
||||
xbmcgui = None
|
||||
xbmcplugin = None
|
||||
|
||||
_ADDON_INSTANCE = None
|
||||
|
||||
def get_addon():
|
||||
global _ADDON_INSTANCE
|
||||
if xbmcaddon is None:
|
||||
return None
|
||||
if _ADDON_INSTANCE is None:
|
||||
_ADDON_INSTANCE = xbmcaddon.Addon()
|
||||
return _ADDON_INSTANCE
|
||||
|
||||
def get_handle() -> int:
|
||||
return int(sys.argv[1]) if len(sys.argv) > 1 else -1
|
||||
|
||||
def get_setting_string(setting_id: str) -> str:
|
||||
addon = get_addon()
|
||||
if addon is None:
|
||||
return ""
|
||||
getter = getattr(addon, "getSettingString", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return str(getter(setting_id) or "")
|
||||
except Exception:
|
||||
pass
|
||||
getter = getattr(addon, "getSetting", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return str(getter(setting_id) or "")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def get_setting_bool(setting_id: str, *, default: bool = False) -> bool:
|
||||
addon = get_addon()
|
||||
if addon is None:
|
||||
return default
|
||||
# Schritt 1: Prüfe ob das Setting überhaupt gesetzt ist (leerer Rohwert = default)
|
||||
raw_getter = getattr(addon, "getSetting", None)
|
||||
if callable(raw_getter):
|
||||
try:
|
||||
raw = str(raw_getter(setting_id) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
except Exception:
|
||||
return default
|
||||
# Schritt 2: Bevorzuge getSettingBool für korrekte Typ-Konvertierung
|
||||
getter = getattr(addon, "getSettingBool", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return bool(getter(setting_id))
|
||||
except Exception:
|
||||
pass
|
||||
# Schritt 3: Fallback – Rohwert manuell parsen
|
||||
if callable(raw_getter):
|
||||
try:
|
||||
raw = str(raw_getter(setting_id) or "").strip().lower()
|
||||
return raw == "true"
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
def get_setting_int(setting_id: str, *, default: int = 0) -> int:
|
||||
addon = get_addon()
|
||||
if addon is None:
|
||||
return default
|
||||
getter = getattr(addon, "getSettingInt", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
raw_getter = getattr(addon, "getSetting", None)
|
||||
if callable(raw_getter):
|
||||
raw = str(raw_getter(setting_id) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
return int(getter(setting_id))
|
||||
except Exception:
|
||||
pass
|
||||
getter = getattr(addon, "getSetting", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
raw = str(getter(setting_id) or "").strip()
|
||||
return int(raw) if raw else default
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
def set_setting_string(setting_id: str, value: str) -> None:
|
||||
addon = get_addon()
|
||||
if addon is None:
|
||||
return
|
||||
setter = getattr(addon, "setSettingString", None)
|
||||
if callable(setter):
|
||||
try:
|
||||
setter(setting_id, str(value))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
setter = getattr(addon, "setSetting", None)
|
||||
if callable(setter):
|
||||
try:
|
||||
setter(setting_id, str(value))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def progress_dialog(heading: str, message: str = ""):
|
||||
"""Zeigt einen Fortschrittsdialog in Kodi und liefert eine Update-Funktion."""
|
||||
dialog = None
|
||||
try:
|
||||
if xbmcgui is not None and hasattr(xbmcgui, "DialogProgress"):
|
||||
dialog = xbmcgui.DialogProgress()
|
||||
dialog.create(heading, message)
|
||||
except Exception:
|
||||
dialog = None
|
||||
|
||||
def _update_fn(percent: int, msg: str = "") -> bool:
|
||||
if dialog:
|
||||
try:
|
||||
dialog.update(percent, msg or message)
|
||||
return dialog.iscanceled()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
try:
|
||||
yield _update_fn
|
||||
finally:
|
||||
if dialog:
|
||||
try:
|
||||
dialog.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def busy_dialog(message: str = "Bitte warten...", *, heading: str = "Bitte warten"):
|
||||
"""Progress-Dialog statt Spinner, mit kurzem Status-Text."""
|
||||
with progress_dialog(heading, message) as progress:
|
||||
progress(10, message)
|
||||
def _update(step_message: str, percent: int | None = None) -> bool:
|
||||
pct = 50 if percent is None else max(5, min(95, int(percent)))
|
||||
return progress(pct, step_message or message)
|
||||
try:
|
||||
yield _update
|
||||
finally:
|
||||
progress(100, "Fertig")
|
||||
|
||||
def run_with_progress(heading: str, message: str, loader: Callable[[], Any]) -> Any:
|
||||
"""Fuehrt eine Ladefunktion mit sichtbarem Fortschrittsdialog aus."""
|
||||
with progress_dialog(heading, message) as progress:
|
||||
progress(10, message)
|
||||
result = loader()
|
||||
progress(100, "Fertig")
|
||||
return result
|
||||
|
||||
def set_content(handle: int, content: str) -> None:
|
||||
"""Hint Kodi about the content type so skins can show watched/resume overlays."""
|
||||
content = (content or "").strip()
|
||||
if not content:
|
||||
return
|
||||
try:
|
||||
setter = getattr(xbmcplugin, "setContent", None)
|
||||
if callable(setter):
|
||||
setter(handle, content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_directory_item(
|
||||
handle: int,
|
||||
label: str,
|
||||
action: str,
|
||||
params: dict[str, str] | None = None,
|
||||
*,
|
||||
is_folder: bool = True,
|
||||
info_labels: dict[str, Any] | None = None,
|
||||
art: dict[str, str] | None = None,
|
||||
cast: Any = None,
|
||||
base_url: str = "",
|
||||
) -> None:
|
||||
"""Fuegt einen Eintrag in die Kodi-Liste ein."""
|
||||
query: dict[str, str] = {"action": action}
|
||||
if params:
|
||||
query.update(params)
|
||||
url = f"{base_url}?{urlencode(query)}"
|
||||
item = xbmcgui.ListItem(label=label)
|
||||
if not is_folder:
|
||||
try:
|
||||
item.setProperty("IsPlayable", "true")
|
||||
except Exception:
|
||||
pass
|
||||
apply_video_info(item, info_labels, cast)
|
||||
if art:
|
||||
setter = getattr(item, "setArt", None)
|
||||
if callable(setter):
|
||||
try:
|
||||
setter(art)
|
||||
except Exception:
|
||||
pass
|
||||
xbmcplugin.addDirectoryItem(handle=handle, url=url, listitem=item, isFolder=is_folder)
|
||||
|
||||
def apply_video_info(item, info_labels: dict[str, Any] | None, cast: Any = None) -> None:
|
||||
"""Setzt Metadaten via InfoTagVideo (Kodi v20+), mit Fallback."""
|
||||
if not info_labels and not cast:
|
||||
return
|
||||
info_labels = dict(info_labels or {})
|
||||
get_tag = getattr(item, "getVideoInfoTag", None)
|
||||
tag = None
|
||||
if callable(get_tag):
|
||||
try:
|
||||
tag = get_tag()
|
||||
except Exception:
|
||||
tag = None
|
||||
|
||||
if tag is not None:
|
||||
try:
|
||||
_apply_tag_info(tag, info_labels)
|
||||
if cast:
|
||||
_apply_tag_cast(tag, cast)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Fallback für ältere Kodi-Versionen
|
||||
setter = getattr(item, "setInfo", None)
|
||||
if callable(setter):
|
||||
try:
|
||||
setter("video", info_labels)
|
||||
except Exception:
|
||||
pass
|
||||
if cast:
|
||||
setter = getattr(item, "setCast", None)
|
||||
if callable(setter):
|
||||
try:
|
||||
setter(cast)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_tag_info(tag, info: dict[str, Any]) -> None:
|
||||
for key, method in [
|
||||
("title", "setTitle"),
|
||||
("plot", "setPlot"),
|
||||
("mediatype", "setMediaType"),
|
||||
("tvshowtitle", "setTvShowTitle"),
|
||||
]:
|
||||
val = info.get(key)
|
||||
if val:
|
||||
setter = getattr(tag, method, None)
|
||||
if callable(setter): setter(str(val))
|
||||
|
||||
for key, method in [("season", "setSeason"), ("episode", "setEpisode")]:
|
||||
val = info.get(key)
|
||||
if val not in (None, "", 0, "0"):
|
||||
setter = getattr(tag, method, None)
|
||||
if callable(setter): setter(int(val))
|
||||
|
||||
rating = info.get("rating")
|
||||
if rating not in (None, "", 0, "0"):
|
||||
set_rating = getattr(tag, "setRating", None)
|
||||
if callable(set_rating):
|
||||
try: set_rating(float(rating))
|
||||
except Exception: pass
|
||||
|
||||
def _apply_tag_cast(tag, cast) -> None:
|
||||
setter = getattr(tag, "setCast", None)
|
||||
if not callable(setter):
|
||||
return
|
||||
try:
|
||||
formatted_cast = []
|
||||
for c in cast:
|
||||
# Erwarte TmdbCastMember oder ähnliches Objekt/Dict
|
||||
name = getattr(c, "name", "") or c.get("name", "") if hasattr(c, "get") else ""
|
||||
role = getattr(c, "role", "") or c.get("role", "") if hasattr(c, "get") else ""
|
||||
thumb = getattr(c, "thumbnail", "") or c.get("thumbnail", "") if hasattr(c, "get") else ""
|
||||
if name:
|
||||
formatted_cast.append(xbmcgui.Actor(name=name, role=role, thumbnail=thumb))
|
||||
if formatted_cast:
|
||||
setter(formatted_cast)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def label_with_duration(label: str, info_labels: dict[str, Any]) -> str:
|
||||
duration = info_labels.get("duration")
|
||||
if not duration:
|
||||
return label
|
||||
try:
|
||||
minutes = int(duration) // 60
|
||||
if minutes > 0:
|
||||
return f"{label} ({minutes} Min.)"
|
||||
except Exception:
|
||||
pass
|
||||
return label
|
||||
|
||||
|
||||
def extract_first_int(value: str | int | None) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
match = re.search(r"\d+", str(value))
|
||||
return int(match.group()) if match else None
|
||||
|
||||
|
||||
def looks_like_unresolved_hoster_link(url: str) -> bool:
|
||||
url = (url or "").strip()
|
||||
return any(p in url.casefold() for p in ["hoster", "link", "resolve"])
|
||||
|
||||
|
||||
def is_resolveurl_missing_error(err: str | None) -> bool:
|
||||
err = str(err or "").strip().lower()
|
||||
return "resolveurl" in err and ("missing" in err or "not found" in err)
|
||||
|
||||
|
||||
def is_cloudflare_challenge_error(err: str | None) -> bool:
|
||||
err = str(err or "").strip().lower()
|
||||
return "cloudflare" in err or "challenge" in err
|
||||
|
||||
|
||||
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 ""
|
||||
448
addon/core/metadata.py
Normal file
448
addon/core/metadata.py
Normal file
@@ -0,0 +1,448 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from tmdb import (
|
||||
TmdbCastMember,
|
||||
fetch_tv_episode_credits,
|
||||
lookup_movie,
|
||||
lookup_tv_season,
|
||||
lookup_tv_season_summary,
|
||||
lookup_tv_show,
|
||||
)
|
||||
|
||||
try:
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
import xbmcvfs
|
||||
except ImportError:
|
||||
xbmc = None
|
||||
xbmcaddon = None
|
||||
xbmcvfs = None
|
||||
|
||||
# Caches
|
||||
_TMDB_CACHE: dict[str, tuple[dict[str, str], dict[str, str]]] = {}
|
||||
_TMDB_CAST_CACHE: dict[str, list[TmdbCastMember]] = {}
|
||||
_TMDB_SEASON_CACHE: dict[tuple[int, int, str, str], dict[int, tuple[dict[str, str], dict[str, str]]]] = {}
|
||||
_TMDB_SEASON_SUMMARY_CACHE: dict[tuple[int, int, str, str], tuple[dict[str, str], dict[str, str]]] = {}
|
||||
_TMDB_EPISODE_CAST_CACHE: dict[tuple[int, int, int, str], list[TmdbCastMember]] = {}
|
||||
_TMDB_ID_CACHE: dict[str, int] = {}
|
||||
_TMDB_LOG_PATH: str | None = None
|
||||
_TMDB_LOCK = threading.RLock()
|
||||
|
||||
|
||||
# Dependency Injection variables
|
||||
_initialized: bool = False
|
||||
_get_setting_string: Callable[[str], str] = lambda k: ""
|
||||
_get_setting_bool: Callable[[str, bool], bool] = lambda k, default=False: default
|
||||
_get_setting_int: Callable[[str, int], int] = lambda k, default=0: default
|
||||
_log: Callable[[str, int], None] = lambda msg, level=0: None
|
||||
_run_async: Callable[[Any], Any] = lambda coro: None
|
||||
_extract_first_int: Callable[[str], Optional[int]] = lambda val: None
|
||||
|
||||
|
||||
def _require_init() -> None:
|
||||
"""Gibt eine Warnung aus, wenn metadata.init() noch nicht aufgerufen wurde."""
|
||||
if not _initialized:
|
||||
import sys
|
||||
print("[ViewIT/metadata] WARNUNG: metadata.init() wurde nicht aufgerufen – Metadaten-Funktionen arbeiten mit Standardwerten!", file=sys.stderr)
|
||||
|
||||
|
||||
def init(
|
||||
*,
|
||||
get_setting_string: Callable[[str], str],
|
||||
get_setting_bool: Callable[..., bool],
|
||||
get_setting_int: Callable[..., int],
|
||||
log_fn: Callable[[str, int], None],
|
||||
run_async_fn: Callable[[Any], Any],
|
||||
extract_first_int_fn: Callable[[str], Optional[int]],
|
||||
) -> None:
|
||||
global _initialized, _get_setting_string, _get_setting_bool, _get_setting_int, _log, _run_async, _extract_first_int
|
||||
_get_setting_string = get_setting_string
|
||||
_get_setting_bool = get_setting_bool
|
||||
_get_setting_int = get_setting_int
|
||||
_log = log_fn
|
||||
_run_async = run_async_fn
|
||||
_extract_first_int = extract_first_int_fn
|
||||
_initialized = True
|
||||
|
||||
|
||||
def _get_log_path(filename: str) -> str:
|
||||
if xbmcaddon and xbmcvfs:
|
||||
addon = xbmcaddon.Addon()
|
||||
profile = xbmcvfs.translatePath(addon.getAddonInfo("profile"))
|
||||
log_dir = os.path.join(profile, "logs")
|
||||
if not xbmcvfs.exists(log_dir):
|
||||
xbmcvfs.mkdirs(log_dir)
|
||||
return os.path.join(log_dir, filename)
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), filename)
|
||||
|
||||
|
||||
def tmdb_file_log(message: str) -> None:
|
||||
global _TMDB_LOG_PATH
|
||||
if _TMDB_LOG_PATH is None:
|
||||
_TMDB_LOG_PATH = _get_log_path("tmdb.log")
|
||||
timestamp = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
||||
line = f"{timestamp}\t{message}\n"
|
||||
try:
|
||||
with open(_TMDB_LOG_PATH, "a", encoding="utf-8") as handle:
|
||||
handle.write(line)
|
||||
except Exception:
|
||||
if xbmcvfs is None:
|
||||
return
|
||||
try:
|
||||
handle = xbmcvfs.File(_TMDB_LOG_PATH, "a") # type: ignore
|
||||
handle.write(line) # type: ignore
|
||||
handle.close() # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def tmdb_cache_get(cache: dict, key, default=None):
|
||||
with _TMDB_LOCK:
|
||||
return cache.get(key, default)
|
||||
|
||||
|
||||
def tmdb_cache_set(cache: dict, key, value) -> None:
|
||||
with _TMDB_LOCK:
|
||||
cache[key] = value
|
||||
|
||||
|
||||
def tmdb_prefetch_concurrency() -> int:
|
||||
try:
|
||||
raw = _get_setting_string("tmdb_prefetch_concurrency").strip()
|
||||
value = int(raw) if raw else 6
|
||||
except Exception:
|
||||
value = 6
|
||||
return max(1, min(20, value))
|
||||
|
||||
|
||||
def tmdb_enabled() -> bool:
|
||||
_require_init()
|
||||
return _get_setting_bool("tmdb_enabled", default=True)
|
||||
|
||||
|
||||
def tmdb_list_enabled() -> bool:
|
||||
return tmdb_enabled() and _get_setting_bool("tmdb_genre_metadata", default=False)
|
||||
|
||||
|
||||
def tmdb_labels_and_art(title: str) -> tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]:
|
||||
if not tmdb_enabled():
|
||||
return {}, {}, []
|
||||
title_key = (title or "").strip().casefold()
|
||||
language = _get_setting_string("tmdb_language").strip() or "de-DE"
|
||||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||||
show_fanart = _get_setting_bool("tmdb_show_fanart", default=True)
|
||||
show_rating = _get_setting_bool("tmdb_show_rating", default=True)
|
||||
show_votes = _get_setting_bool("tmdb_show_votes", default=False)
|
||||
show_cast = _get_setting_bool("tmdb_show_cast", default=False)
|
||||
flags = f"p{int(show_plot)}a{int(show_art)}f{int(show_fanart)}r{int(show_rating)}v{int(show_votes)}c{int(show_cast)}"
|
||||
cache_key = f"{language}|{flags}|{title_key}"
|
||||
cached = tmdb_cache_get(_TMDB_CACHE, cache_key)
|
||||
if cached is not None:
|
||||
info, art = cached
|
||||
cast_cached = tmdb_cache_get(_TMDB_CAST_CACHE, cache_key, [])
|
||||
return info, art, list(cast_cached)
|
||||
|
||||
info_labels: dict[str, str] = {"title": title}
|
||||
art: dict[str, str] = {}
|
||||
cast: list[TmdbCastMember] = []
|
||||
query = (title or "").strip()
|
||||
api_key = _get_setting_string("tmdb_api_key").strip()
|
||||
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
|
||||
log_responses = _get_setting_bool("tmdb_log_responses", default=False)
|
||||
if api_key:
|
||||
try:
|
||||
log_fn = tmdb_file_log if (log_requests or log_responses) else None
|
||||
candidates: list[str] = []
|
||||
if query:
|
||||
candidates.append(query)
|
||||
simplified = re.sub(r"\s*[-–]\s*der\s+film\s*$", "", query, flags=re.IGNORECASE).strip()
|
||||
if simplified and simplified not in candidates:
|
||||
candidates.append(simplified)
|
||||
|
||||
meta = None
|
||||
is_tv = False
|
||||
for candidate in candidates:
|
||||
meta = lookup_tv_show(
|
||||
title=candidate,
|
||||
api_key=api_key,
|
||||
language=language,
|
||||
log=log_fn,
|
||||
log_responses=log_responses,
|
||||
include_cast=show_cast,
|
||||
)
|
||||
if meta:
|
||||
is_tv = True
|
||||
break
|
||||
if not meta:
|
||||
for candidate in candidates:
|
||||
movie = lookup_movie(
|
||||
title=candidate,
|
||||
api_key=api_key,
|
||||
language=language,
|
||||
log=log_fn,
|
||||
log_responses=log_responses,
|
||||
include_cast=show_cast,
|
||||
)
|
||||
if movie:
|
||||
meta = movie
|
||||
break
|
||||
except Exception as exc:
|
||||
try:
|
||||
tmdb_file_log(f"TMDB ERROR lookup_failed title={title!r} error={exc!r}")
|
||||
except Exception:
|
||||
pass
|
||||
_log(f"TMDB Meta fehlgeschlagen: {exc}", 1) # LOGWARNING/LOGDEBUG fallback
|
||||
meta = None
|
||||
if meta:
|
||||
if is_tv:
|
||||
tmdb_cache_set(_TMDB_ID_CACHE, title_key, int(getattr(meta, "tmdb_id", 0) or 0))
|
||||
info_labels.setdefault("mediatype", "tvshow")
|
||||
else:
|
||||
info_labels.setdefault("mediatype", "movie")
|
||||
if show_plot and getattr(meta, "plot", ""):
|
||||
info_labels["plot"] = getattr(meta, "plot", "")
|
||||
runtime_minutes = int(getattr(meta, "runtime_minutes", 0) or 0)
|
||||
if runtime_minutes > 0 and not is_tv:
|
||||
info_labels["duration"] = str(runtime_minutes * 60)
|
||||
rating = getattr(meta, "rating", 0.0) or 0.0
|
||||
votes = getattr(meta, "votes", 0) or 0
|
||||
if show_rating and rating:
|
||||
info_labels["rating"] = str(rating)
|
||||
if show_votes and votes:
|
||||
info_labels["votes"] = str(votes)
|
||||
if show_art and getattr(meta, "poster", ""):
|
||||
poster = getattr(meta, "poster", "")
|
||||
art.update({"thumb": poster, "poster": poster, "icon": poster})
|
||||
if show_fanart and getattr(meta, "fanart", ""):
|
||||
fanart = getattr(meta, "fanart", "")
|
||||
if fanart:
|
||||
art.update({"fanart": fanart, "landscape": fanart})
|
||||
if show_cast:
|
||||
cast = list(getattr(meta, "cast", []) or [])
|
||||
elif log_requests or log_responses:
|
||||
tmdb_file_log(f"TMDB MISS title={title!r}")
|
||||
|
||||
tmdb_cache_set(_TMDB_CACHE, cache_key, (info_labels, art))
|
||||
tmdb_cache_set(_TMDB_CAST_CACHE, cache_key, list(cast))
|
||||
return info_labels, art, list(cast)
|
||||
|
||||
|
||||
async def _tmdb_labels_and_art_bulk_async(
|
||||
titles: list[str],
|
||||
) -> dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]]:
|
||||
titles = [str(t).strip() for t in (titles or []) if t and str(t).strip()]
|
||||
if not titles:
|
||||
return {}
|
||||
|
||||
unique_titles: list[str] = list(dict.fromkeys(titles))
|
||||
limit = tmdb_prefetch_concurrency()
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def fetch_one(title: str):
|
||||
async with semaphore:
|
||||
return title, await asyncio.to_thread(tmdb_labels_and_art, title)
|
||||
|
||||
tasks = [fetch_one(title) for title in unique_titles]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
mapped: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||||
for entry in results:
|
||||
if isinstance(entry, Exception):
|
||||
continue
|
||||
try:
|
||||
title, payload = entry
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(title, str) and isinstance(payload, tuple) and len(payload) == 3:
|
||||
mapped[title] = payload # type: ignore[assignment]
|
||||
return mapped
|
||||
|
||||
|
||||
def tmdb_labels_and_art_bulk(
|
||||
titles: list[str],
|
||||
) -> dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]]:
|
||||
if not tmdb_enabled():
|
||||
return {}
|
||||
return _run_async(_tmdb_labels_and_art_bulk_async(titles))
|
||||
|
||||
|
||||
def tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label: str) -> tuple[dict[str, str], dict[str, str]]:
|
||||
if not tmdb_enabled():
|
||||
return {"title": episode_label}, {}
|
||||
title_key = (title or "").strip().casefold()
|
||||
tmdb_id = tmdb_cache_get(_TMDB_ID_CACHE, title_key)
|
||||
if not tmdb_id:
|
||||
tmdb_labels_and_art(title)
|
||||
tmdb_id = tmdb_cache_get(_TMDB_ID_CACHE, title_key)
|
||||
if not tmdb_id:
|
||||
return {"title": episode_label}, {}
|
||||
|
||||
season_number = _extract_first_int(season_label)
|
||||
episode_number = _extract_first_int(episode_label)
|
||||
if season_number is None or episode_number is None:
|
||||
return {"title": episode_label}, {}
|
||||
|
||||
language = _get_setting_string("tmdb_language").strip() or "de-DE"
|
||||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||||
flags = f"p{int(show_plot)}a{int(show_art)}"
|
||||
season_key = (tmdb_id, season_number, language, flags)
|
||||
cached_season = tmdb_cache_get(_TMDB_SEASON_CACHE, season_key)
|
||||
if cached_season is None:
|
||||
api_key = _get_setting_string("tmdb_api_key").strip()
|
||||
if not api_key:
|
||||
return {"title": episode_label}, {}
|
||||
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
|
||||
log_responses = _get_setting_bool("tmdb_log_responses", default=False)
|
||||
log_fn = tmdb_file_log if (log_requests or log_responses) else None
|
||||
try:
|
||||
season_meta = lookup_tv_season(
|
||||
tmdb_id=tmdb_id,
|
||||
season_number=season_number,
|
||||
api_key=api_key,
|
||||
language=language,
|
||||
log=log_fn,
|
||||
log_responses=log_responses,
|
||||
)
|
||||
except Exception as exc:
|
||||
if log_fn:
|
||||
log_fn(f"TMDB ERROR season_lookup_failed tmdb_id={tmdb_id} season={season_number} error={exc!r}")
|
||||
season_meta = None
|
||||
mapped: dict[int, tuple[dict[str, str], dict[str, str]]] = {}
|
||||
if season_meta:
|
||||
for ep_no, ep in season_meta.items():
|
||||
info: dict[str, str] = {"title": f"Episode {ep_no}"}
|
||||
if show_plot and ep.plot:
|
||||
info["plot"] = ep.plot
|
||||
if getattr(ep, "runtime_minutes", 0):
|
||||
info["duration"] = str(int(getattr(ep, "runtime_minutes", 0)) * 60)
|
||||
art: dict[str, str] = {}
|
||||
if show_art and ep.thumb:
|
||||
art = {"thumb": ep.thumb}
|
||||
mapped[ep_no] = (info, art)
|
||||
tmdb_cache_set(_TMDB_SEASON_CACHE, season_key, mapped)
|
||||
cached_season = mapped
|
||||
|
||||
return cached_season.get(episode_number, ({"title": episode_label}, {}))
|
||||
|
||||
|
||||
def tmdb_episode_cast(*, title: str, season_label: str, episode_label: str) -> list[TmdbCastMember]:
|
||||
if not tmdb_enabled():
|
||||
return []
|
||||
show_episode_cast = _get_setting_bool("tmdb_show_episode_cast", default=False)
|
||||
if not show_episode_cast:
|
||||
return []
|
||||
|
||||
title_key = (title or "").strip().casefold()
|
||||
tmdb_id = tmdb_cache_get(_TMDB_ID_CACHE, title_key)
|
||||
if not tmdb_id:
|
||||
tmdb_labels_and_art(title)
|
||||
tmdb_id = tmdb_cache_get(_TMDB_ID_CACHE, title_key)
|
||||
if not tmdb_id:
|
||||
return []
|
||||
|
||||
season_number = _extract_first_int(season_label)
|
||||
episode_number = _extract_first_int(episode_label)
|
||||
if season_number is None or episode_number is None:
|
||||
return []
|
||||
|
||||
language = _get_setting_string("tmdb_language").strip() or "de-DE"
|
||||
cache_key = (tmdb_id, season_number, episode_number, language)
|
||||
cached = tmdb_cache_get(_TMDB_EPISODE_CAST_CACHE, cache_key)
|
||||
if cached is not None:
|
||||
return list(cached)
|
||||
|
||||
api_key = _get_setting_string("tmdb_api_key").strip()
|
||||
if not api_key:
|
||||
tmdb_cache_set(_TMDB_EPISODE_CAST_CACHE, cache_key, [])
|
||||
return []
|
||||
|
||||
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
|
||||
log_responses = _get_setting_bool("tmdb_log_responses", default=False)
|
||||
log_fn = tmdb_file_log if (log_requests or log_responses) else None
|
||||
try:
|
||||
cast = fetch_tv_episode_credits(
|
||||
tmdb_id=tmdb_id,
|
||||
season_number=season_number,
|
||||
episode_number=episode_number,
|
||||
api_key=api_key,
|
||||
language=language,
|
||||
log=log_fn,
|
||||
log_responses=log_responses,
|
||||
)
|
||||
except Exception as exc:
|
||||
if log_fn:
|
||||
log_fn(
|
||||
f"TMDB ERROR episode_credits_failed tmdb_id={tmdb_id} season={season_number} episode={episode_number} error={exc!r}"
|
||||
)
|
||||
cast = []
|
||||
tmdb_cache_set(_TMDB_EPISODE_CAST_CACHE, cache_key, list(cast))
|
||||
return list(cast)
|
||||
|
||||
|
||||
def tmdb_season_labels_and_art(
|
||||
*,
|
||||
title: str,
|
||||
season: str,
|
||||
title_info_labels: dict[str, str] | None = None,
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
if not tmdb_enabled():
|
||||
return {"title": season}, {}
|
||||
language = _get_setting_string("tmdb_language").strip() or "de-DE"
|
||||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||||
flags = f"p{int(show_plot)}a{int(show_art)}"
|
||||
api_key = _get_setting_string("tmdb_api_key").strip()
|
||||
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
|
||||
log_responses = _get_setting_bool("tmdb_log_responses", default=False)
|
||||
log_fn = tmdb_file_log if (log_requests or log_responses) else None
|
||||
|
||||
info_labels: dict[str, str] | None = None
|
||||
art: dict[str, str] | None = None
|
||||
season_number = _extract_first_int(season)
|
||||
|
||||
if api_key and season_number is not None:
|
||||
title_key = (title or "").strip().casefold()
|
||||
tmdb_id = tmdb_cache_get(_TMDB_ID_CACHE, title_key) or 0
|
||||
cache_key = (tmdb_id, season_number, language, flags)
|
||||
cached = tmdb_cache_get(_TMDB_SEASON_SUMMARY_CACHE, cache_key)
|
||||
|
||||
if cached is None and tmdb_id:
|
||||
try:
|
||||
meta = lookup_tv_season_summary(
|
||||
tmdb_id=tmdb_id,
|
||||
season_number=season_number,
|
||||
api_key=api_key,
|
||||
language=language,
|
||||
log=log_fn,
|
||||
log_responses=log_responses,
|
||||
)
|
||||
except Exception as exc:
|
||||
if log_fn:
|
||||
log_fn(f"TMDB ERROR season_summary_failed tmdb_id={tmdb_id} season={season_number} error={exc!r}")
|
||||
meta = None
|
||||
labels = {"title": season}
|
||||
art_map: dict[str, str] = {}
|
||||
if meta:
|
||||
if show_plot and meta.plot:
|
||||
labels["plot"] = meta.plot
|
||||
if show_art and meta.poster:
|
||||
art_map = {"thumb": meta.poster, "poster": meta.poster}
|
||||
cached = (labels, art_map)
|
||||
tmdb_cache_set(_TMDB_SEASON_SUMMARY_CACHE, cache_key, cached)
|
||||
|
||||
if cached is not None:
|
||||
info_labels, art = cached
|
||||
|
||||
merged_labels = dict(info_labels or {})
|
||||
if title_info_labels:
|
||||
merged_labels = dict(title_info_labels)
|
||||
merged_labels.update(dict(info_labels or {}))
|
||||
|
||||
return merged_labels, art or {}
|
||||
54
addon/core/playstate.py
Normal file
54
addon/core/playstate.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
# Playstate-Verwaltung für den ViewIT Kodi Addon.
|
||||
# Aktuell sind die meisten Funktionen Stubs, da Kodi die Wiedergabe-Stände selbst verwaltet.
|
||||
|
||||
_PLAYSTATE_CACHE: dict[str, dict[str, Any]] | None = None
|
||||
_PLAYSTATE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def playstate_key(*, plugin_name: str, title: str, season: str, episode: str) -> str:
|
||||
plugin_name = (plugin_name or "").strip()
|
||||
title = (title or "").strip()
|
||||
season = (season or "").strip()
|
||||
episode = (episode or "").strip()
|
||||
return f"{plugin_name}\t{title}\t{season}\t{episode}"
|
||||
|
||||
|
||||
def load_playstate() -> dict[str, dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
|
||||
def save_playstate(state: dict[str, dict[str, Any]]) -> None:
|
||||
return
|
||||
|
||||
|
||||
def get_playstate(key: str) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def set_playstate(key: str, value: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
|
||||
def apply_playstate_to_info(info_labels: dict[str, Any], playstate: dict[str, Any]) -> dict[str, Any]:
|
||||
return dict(info_labels or {})
|
||||
|
||||
|
||||
def label_with_playstate(label: str, playstate: dict[str, Any]) -> str:
|
||||
return label
|
||||
|
||||
|
||||
def title_playstate(plugin_name: str, title: str) -> dict[str, Any]:
|
||||
return get_playstate(playstate_key(plugin_name=plugin_name, title=title, season="", episode=""))
|
||||
|
||||
|
||||
def season_playstate(plugin_name: str, title: str, season: str) -> dict[str, Any]:
|
||||
return get_playstate(playstate_key(plugin_name=plugin_name, title=title, season=season, episode=""))
|
||||
|
||||
|
||||
def track_playback_and_update_state_async(key: str) -> None:
|
||||
# Eigenes Resume/Watched ist deaktiviert; Kodi verwaltet das selbst.
|
||||
return
|
||||
158
addon/core/plugin_manager.py
Normal file
158
addon/core/plugin_manager.py
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plugin-Erkennung und -Verwaltung fuer ViewIT.
|
||||
|
||||
Dieses Modul laedt dynamisch alle Plugins aus dem `plugins/` Verzeichnis,
|
||||
instanziiert sie und cached die Instanzen im RAM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
try: # pragma: no cover - Kodi runtime
|
||||
import xbmc # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover
|
||||
xbmc = None
|
||||
|
||||
from plugin_interface import BasisPlugin
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugins"
|
||||
_PLUGIN_CACHE: dict[str, BasisPlugin] | None = None
|
||||
|
||||
|
||||
def _log(message: str, level: int = 1) -> None:
|
||||
if xbmc is not None:
|
||||
xbmc.log(f"[ViewIt] {message}", level)
|
||||
|
||||
|
||||
def import_plugin_module(path: Path) -> ModuleType:
|
||||
"""Importiert eine einzelne Plugin-Datei als Python-Modul."""
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Modul-Spezifikation fuer {path.name} fehlt.")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(spec.name, None)
|
||||
raise
|
||||
return module
|
||||
|
||||
|
||||
def discover_plugins() -> dict[str, BasisPlugin]:
|
||||
"""Laedt alle Plugins aus `plugins/*.py` und cached Instanzen im RAM."""
|
||||
global _PLUGIN_CACHE
|
||||
if _PLUGIN_CACHE is not None:
|
||||
return _PLUGIN_CACHE
|
||||
|
||||
plugins: dict[str, BasisPlugin] = {}
|
||||
if not PLUGIN_DIR.exists():
|
||||
_PLUGIN_CACHE = plugins
|
||||
return plugins
|
||||
|
||||
for file_path in sorted(PLUGIN_DIR.glob("*.py")):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
module = import_plugin_module(file_path)
|
||||
except Exception as exc:
|
||||
_log(f"Plugin-Datei {file_path.name} konnte nicht geladen werden: {exc}", 2)
|
||||
continue
|
||||
|
||||
preferred = getattr(module, "Plugin", None)
|
||||
if inspect.isclass(preferred) and issubclass(preferred, BasisPlugin) and preferred is not BasisPlugin:
|
||||
plugin_classes = [preferred]
|
||||
else:
|
||||
plugin_classes = [
|
||||
obj
|
||||
for obj in module.__dict__.values()
|
||||
if inspect.isclass(obj) and issubclass(obj, BasisPlugin) and obj is not BasisPlugin
|
||||
]
|
||||
plugin_classes.sort(key=lambda cls: cls.__name__.casefold())
|
||||
|
||||
for cls in plugin_classes:
|
||||
try:
|
||||
instance = cls()
|
||||
except Exception as exc:
|
||||
_log(f"Plugin {cls.__name__} konnte nicht geladen werden: {exc}", 2)
|
||||
continue
|
||||
if getattr(instance, "is_available", True) is False:
|
||||
reason = getattr(instance, "unavailable_reason", "Nicht verfuegbar.")
|
||||
_log(f"Plugin {cls.__name__} deaktiviert: {reason}", 2)
|
||||
continue
|
||||
plugin_name = str(getattr(instance, "name", "") or "").strip()
|
||||
if not plugin_name:
|
||||
_log(
|
||||
f"Plugin {cls.__name__} wurde ohne Name registriert und wird uebersprungen.",
|
||||
2,
|
||||
)
|
||||
continue
|
||||
if plugin_name in plugins:
|
||||
_log(
|
||||
f"Plugin-Name doppelt ({plugin_name}), {cls.__name__} wird uebersprungen.",
|
||||
2,
|
||||
)
|
||||
continue
|
||||
plugins[plugin_name] = instance
|
||||
|
||||
plugins = dict(sorted(plugins.items(), key=lambda item: item[0].casefold()))
|
||||
_PLUGIN_CACHE = plugins
|
||||
return plugins
|
||||
|
||||
|
||||
def plugin_has_capability(plugin: BasisPlugin, capability: str) -> bool:
|
||||
"""Prueft ob ein Plugin eine bestimmte Faehigkeit hat."""
|
||||
getter = getattr(plugin, "capabilities", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
capabilities = getter()
|
||||
except Exception:
|
||||
capabilities = set()
|
||||
try:
|
||||
return capability in set(capabilities or [])
|
||||
except Exception:
|
||||
return False
|
||||
# Backwards compatibility: Popular via POPULAR_GENRE_LABEL constant.
|
||||
if capability == "popular_series":
|
||||
return _popular_genre_label(plugin) is not None
|
||||
return False
|
||||
|
||||
|
||||
def _popular_genre_label(plugin: BasisPlugin) -> str | None:
|
||||
label = getattr(plugin, "POPULAR_GENRE_LABEL", None)
|
||||
if isinstance(label, str) and label.strip():
|
||||
return label.strip()
|
||||
return None
|
||||
|
||||
|
||||
def popular_genre_label(plugin: BasisPlugin) -> str | None:
|
||||
"""Gibt das POPULAR_GENRE_LABEL des Plugins zurueck, falls vorhanden."""
|
||||
return _popular_genre_label(plugin)
|
||||
|
||||
|
||||
def plugins_with_popular() -> list[tuple[str, BasisPlugin, str]]:
|
||||
"""Liefert alle Plugins die 'popular_series' unterstuetzen."""
|
||||
results: list[tuple[str, BasisPlugin, str]] = []
|
||||
for plugin_name, plugin in discover_plugins().items():
|
||||
if not plugin_has_capability(plugin, "popular_series"):
|
||||
continue
|
||||
label = _popular_genre_label(plugin) or ""
|
||||
results.append((plugin_name, plugin, label))
|
||||
return results
|
||||
|
||||
|
||||
def series_url_params(plugin: BasisPlugin, title: str) -> dict[str, str]:
|
||||
"""Liefert series_url Parameter fuer Kodi-Navigation, falls vom Plugin bereitgestellt."""
|
||||
getter = getattr(plugin, "series_url_for_title", None)
|
||||
if not callable(getter):
|
||||
return {}
|
||||
try:
|
||||
series_url = str(getter(title) or "").strip()
|
||||
except Exception:
|
||||
return {}
|
||||
return {"series_url": series_url} if series_url else {}
|
||||
58
addon/core/router.py
Normal file
58
addon/core/router.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
|
||||
class Router:
|
||||
"""A simple router for Kodi add-ons."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._routes: Dict[str, Callable[[Dict[str, str]], Any]] = {}
|
||||
self._fallback: Optional[Callable[[Dict[str, str]], Any]] = None
|
||||
|
||||
def route(self, action: str) -> Callable[[Callable[[Dict[str, str]], Any]], Callable[[Dict[str, str]], Any]]:
|
||||
"""Decorator to register a function for a specific action."""
|
||||
def decorator(handler: Callable[[Dict[str, str]], Any]) -> Callable[[Dict[str, str]], Any]:
|
||||
self._routes[action] = handler
|
||||
return handler
|
||||
return decorator
|
||||
|
||||
def fallback(self) -> Callable[[Callable[[Dict[str, str]], Any]], Callable[[Dict[str, str]], Any]]:
|
||||
"""Decorator to register the fallback (default) handler."""
|
||||
def decorator(handler: Callable[[Dict[str, str]], Any]) -> Callable[[Dict[str, str]], Any]:
|
||||
self._fallback = handler
|
||||
return handler
|
||||
return decorator
|
||||
|
||||
def dispatch(self, action: Optional[str] = None, params: Optional[Dict[str, str]] = None) -> Any:
|
||||
"""Dispatch the request to the registered handler."""
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
handler = self._routes.get(action) if action else self._fallback
|
||||
if not handler:
|
||||
handler = self._fallback
|
||||
|
||||
if handler:
|
||||
return handler(params)
|
||||
|
||||
raise KeyError(f"No route or fallback defined for action: {action}")
|
||||
|
||||
|
||||
def parse_params(argv: Optional[list[str]] = None) -> dict[str, str]:
|
||||
"""Parst Kodi-Plugin-Parameter aus `sys.argv[2]` oder der übergebenen Liste."""
|
||||
if argv is None:
|
||||
argv = sys.argv
|
||||
if len(argv) <= 2 or not argv[2]:
|
||||
return {}
|
||||
raw_params = parse_qs(argv[2].lstrip("?"), keep_blank_values=True)
|
||||
return {key: values[0] for key, values in raw_params.items()}
|
||||
|
||||
|
||||
def parse_positive_int(value: str, *, default: int = 1) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
return parsed if parsed > 0 else default
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
439
addon/core/trakt.py
Normal file
439
addon/core/trakt.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""Trakt.tv API-Integration fuer ViewIT.
|
||||
|
||||
Bietet OAuth-Device-Auth, Scrobbling, Watchlist, History und Calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
TRAKT_API_BASE = "https://api.trakt.tv"
|
||||
TRAKT_API_VERSION = "2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class TraktToken:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_at: int # Unix-Timestamp
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraktDeviceCode:
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_url: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraktMediaIds:
|
||||
trakt: int = 0
|
||||
tmdb: int = 0
|
||||
imdb: str = ""
|
||||
slug: str = ""
|
||||
tvdb: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraktItem:
|
||||
title: str
|
||||
year: int
|
||||
media_type: str # "movie" oder "show"
|
||||
ids: TraktMediaIds = field(default_factory=TraktMediaIds)
|
||||
season: int = 0
|
||||
episode: int = 0
|
||||
watched_at: str = ""
|
||||
poster: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraktCalendarItem:
|
||||
"""Ein Eintrag aus dem Trakt-Kalender (anstehende Episode)."""
|
||||
show_title: str
|
||||
show_year: int
|
||||
show_ids: TraktMediaIds
|
||||
season: int
|
||||
episode: int
|
||||
episode_title: str
|
||||
first_aired: str # ISO-8601, z.B. "2026-03-02T02:00:00.000Z"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TraktClient:
|
||||
"""Trakt API Client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
*,
|
||||
log: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._log = log
|
||||
|
||||
def _headers(self, token: str = "") -> dict[str, str]:
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"trakt-api-version": TRAKT_API_VERSION,
|
||||
"trakt-api-key": self._client_id,
|
||||
}
|
||||
if token:
|
||||
h["Authorization"] = f"Bearer {token}"
|
||||
return h
|
||||
|
||||
def _do_log(self, msg: str) -> None:
|
||||
if callable(self._log):
|
||||
self._log(f"[Trakt] {msg}")
|
||||
|
||||
def _post(self, path: str, body: dict, *, token: str = "", timeout: int = 15) -> tuple[int, dict | None]:
|
||||
if requests is None:
|
||||
return 0, None
|
||||
url = f"{TRAKT_API_BASE}{path}"
|
||||
self._do_log(f"POST {path}")
|
||||
try:
|
||||
resp = requests.post(url, json=body, headers=self._headers(token), timeout=timeout)
|
||||
status = resp.status_code
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
payload = None
|
||||
self._do_log(f"POST {path} -> {status}")
|
||||
return status, payload
|
||||
except Exception as exc:
|
||||
self._do_log(f"POST {path} FEHLER: {exc}")
|
||||
return 0, None
|
||||
|
||||
def _get(self, path: str, *, token: str = "", timeout: int = 15) -> tuple[int, Any]:
|
||||
if requests is None:
|
||||
return 0, None
|
||||
url = f"{TRAKT_API_BASE}{path}"
|
||||
self._do_log(f"GET {path}")
|
||||
try:
|
||||
resp = requests.get(url, headers=self._headers(token), timeout=timeout)
|
||||
status = resp.status_code
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
payload = None
|
||||
self._do_log(f"GET {path} -> {status}")
|
||||
return status, payload
|
||||
except Exception as exc:
|
||||
self._do_log(f"GET {path} FEHLER: {exc}")
|
||||
return 0, None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# OAuth Device Flow
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def device_code_request(self) -> TraktDeviceCode | None:
|
||||
"""POST /oauth/device/code – generiert User-Code + Verification-URL."""
|
||||
status, payload = self._post("/oauth/device/code", {"client_id": self._client_id})
|
||||
if status != 200 or not isinstance(payload, dict):
|
||||
return None
|
||||
return TraktDeviceCode(
|
||||
device_code=payload.get("device_code", ""),
|
||||
user_code=payload.get("user_code", ""),
|
||||
verification_url=payload.get("verification_url", "https://trakt.tv/activate"),
|
||||
expires_in=int(payload.get("expires_in", 600)),
|
||||
interval=int(payload.get("interval", 5)),
|
||||
)
|
||||
|
||||
def poll_device_token(self, device_code: str, *, interval: int = 5, expires_in: int = 600) -> TraktToken | None:
|
||||
"""Pollt POST /oauth/device/token bis autorisiert oder Timeout."""
|
||||
body = {
|
||||
"code": device_code,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
}
|
||||
start = time.time()
|
||||
while time.time() - start < expires_in:
|
||||
status, payload = self._post("/oauth/device/token", body)
|
||||
if status == 200 and isinstance(payload, dict):
|
||||
return TraktToken(
|
||||
access_token=payload.get("access_token", ""),
|
||||
refresh_token=payload.get("refresh_token", ""),
|
||||
expires_at=int(payload.get("created_at", 0)) + int(payload.get("expires_in", 0)),
|
||||
created_at=int(payload.get("created_at", 0)),
|
||||
)
|
||||
if status == 400:
|
||||
# Pending – weiter warten
|
||||
time.sleep(interval)
|
||||
continue
|
||||
if status in (404, 410, 418):
|
||||
# Ungueltig, abgelaufen oder abgelehnt
|
||||
self._do_log(f"Device-Auth abgebrochen: status={status}")
|
||||
return None
|
||||
if status == 429:
|
||||
time.sleep(interval + 1)
|
||||
continue
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
def refresh_token(self, refresh_tok: str) -> TraktToken | None:
|
||||
"""POST /oauth/token – Token erneuern."""
|
||||
body = {
|
||||
"refresh_token": refresh_tok,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
status, payload = self._post("/oauth/token", body)
|
||||
if status != 200 or not isinstance(payload, dict):
|
||||
return None
|
||||
return TraktToken(
|
||||
access_token=payload.get("access_token", ""),
|
||||
refresh_token=payload.get("refresh_token", ""),
|
||||
expires_at=int(payload.get("created_at", 0)) + int(payload.get("expires_in", 0)),
|
||||
created_at=int(payload.get("created_at", 0)),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Scrobble
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _build_scrobble_body(
|
||||
self,
|
||||
*,
|
||||
media_type: str,
|
||||
title: str,
|
||||
tmdb_id: int,
|
||||
imdb_id: str = "",
|
||||
season: int = 0,
|
||||
episode: int = 0,
|
||||
progress: float = 0.0,
|
||||
) -> dict:
|
||||
ids: dict[str, object] = {}
|
||||
if tmdb_id:
|
||||
ids["tmdb"] = tmdb_id
|
||||
if imdb_id:
|
||||
ids["imdb"] = imdb_id
|
||||
|
||||
body: dict[str, object] = {"progress": round(progress, 1)}
|
||||
|
||||
if media_type == "tv" and season > 0 and episode > 0:
|
||||
body["show"] = {"title": title, "ids": ids}
|
||||
body["episode"] = {"season": season, "number": episode}
|
||||
else:
|
||||
body["movie"] = {"title": title, "ids": ids}
|
||||
|
||||
return body
|
||||
|
||||
def scrobble_start(
|
||||
self, token: str, *, media_type: str, title: str,
|
||||
tmdb_id: int, imdb_id: str = "",
|
||||
season: int = 0, episode: int = 0, progress: float = 0.0,
|
||||
) -> bool:
|
||||
"""POST /scrobble/start"""
|
||||
body = self._build_scrobble_body(
|
||||
media_type=media_type, title=title, tmdb_id=tmdb_id, imdb_id=imdb_id,
|
||||
season=season, episode=episode, progress=progress,
|
||||
)
|
||||
status, _ = self._post("/scrobble/start", body, token=token)
|
||||
return status in (200, 201)
|
||||
|
||||
def scrobble_pause(
|
||||
self, token: str, *, media_type: str, title: str,
|
||||
tmdb_id: int, imdb_id: str = "",
|
||||
season: int = 0, episode: int = 0, progress: float = 50.0,
|
||||
) -> bool:
|
||||
"""POST /scrobble/pause"""
|
||||
body = self._build_scrobble_body(
|
||||
media_type=media_type, title=title, tmdb_id=tmdb_id, imdb_id=imdb_id,
|
||||
season=season, episode=episode, progress=progress,
|
||||
)
|
||||
status, _ = self._post("/scrobble/pause", body, token=token)
|
||||
return status in (200, 201)
|
||||
|
||||
def scrobble_stop(
|
||||
self, token: str, *, media_type: str, title: str,
|
||||
tmdb_id: int, imdb_id: str = "",
|
||||
season: int = 0, episode: int = 0, progress: float = 100.0,
|
||||
) -> bool:
|
||||
"""POST /scrobble/stop"""
|
||||
body = self._build_scrobble_body(
|
||||
media_type=media_type, title=title, tmdb_id=tmdb_id, imdb_id=imdb_id,
|
||||
season=season, episode=episode, progress=progress,
|
||||
)
|
||||
status, _ = self._post("/scrobble/stop", body, token=token)
|
||||
return status in (200, 201)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Watchlist
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def get_watchlist(self, token: str, *, media_type: str = "") -> list[TraktItem]:
|
||||
"""GET /users/me/watchlist[/movies|/shows]"""
|
||||
path = "/users/me/watchlist"
|
||||
if media_type in ("movies", "shows"):
|
||||
path = f"{path}/{media_type}"
|
||||
status, payload = self._get(path, token=token)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
return []
|
||||
return self._parse_list_items(payload)
|
||||
|
||||
def add_to_watchlist(
|
||||
self, token: str, *, media_type: str, tmdb_id: int, imdb_id: str = "",
|
||||
) -> bool:
|
||||
"""POST /sync/watchlist"""
|
||||
ids: dict[str, object] = {}
|
||||
if tmdb_id:
|
||||
ids["tmdb"] = tmdb_id
|
||||
if imdb_id:
|
||||
ids["imdb"] = imdb_id
|
||||
key = "movies" if media_type == "movie" else "shows"
|
||||
body = {key: [{"ids": ids}]}
|
||||
status, _ = self._post("/sync/watchlist", body, token=token)
|
||||
return status in (200, 201)
|
||||
|
||||
def remove_from_watchlist(
|
||||
self, token: str, *, media_type: str, tmdb_id: int, imdb_id: str = "",
|
||||
) -> bool:
|
||||
"""POST /sync/watchlist/remove"""
|
||||
ids: dict[str, object] = {}
|
||||
if tmdb_id:
|
||||
ids["tmdb"] = tmdb_id
|
||||
if imdb_id:
|
||||
ids["imdb"] = imdb_id
|
||||
key = "movies" if media_type == "movie" else "shows"
|
||||
body = {key: [{"ids": ids}]}
|
||||
status, _ = self._post("/sync/watchlist/remove", body, token=token)
|
||||
return status == 200
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# History
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def get_history(
|
||||
self, token: str, *, media_type: str = "", page: int = 1, limit: int = 20,
|
||||
) -> list[TraktItem]:
|
||||
"""GET /users/me/history[/movies|/shows|/episodes]"""
|
||||
path = "/users/me/history"
|
||||
if media_type in ("movies", "shows", "episodes"):
|
||||
path = f"{path}/{media_type}"
|
||||
path = f"{path}?page={page}&limit={limit}"
|
||||
status, payload = self._get(path, token=token)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
return []
|
||||
return self._parse_history_items(payload)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Calendar
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def get_calendar(self, token: str, start_date: str = "", days: int = 7) -> list[TraktCalendarItem]:
|
||||
"""GET /calendars/my/shows/{start_date}/{days}
|
||||
|
||||
start_date: YYYY-MM-DD (leer = heute).
|
||||
Liefert anstehende Episoden der eigenen Watchlist-Serien.
|
||||
"""
|
||||
if not start_date:
|
||||
from datetime import date
|
||||
start_date = date.today().strftime("%Y-%m-%d")
|
||||
path = f"/calendars/my/shows/{start_date}/{days}"
|
||||
status, payload = self._get(path, token=token)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
return []
|
||||
items: list[TraktCalendarItem] = []
|
||||
for entry in payload:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
show = entry.get("show") or {}
|
||||
ep = entry.get("episode") or {}
|
||||
show_ids = self._parse_ids(show.get("ids") or {})
|
||||
items.append(TraktCalendarItem(
|
||||
show_title=str(show.get("title", "") or ""),
|
||||
show_year=int(show.get("year", 0) or 0),
|
||||
show_ids=show_ids,
|
||||
season=int(ep.get("season", 0) or 0),
|
||||
episode=int(ep.get("number", 0) or 0),
|
||||
episode_title=str(ep.get("title", "") or ""),
|
||||
first_aired=str(entry.get("first_aired", "") or ""),
|
||||
))
|
||||
return items
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Parser
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_ids(ids_dict: dict) -> TraktMediaIds:
|
||||
return TraktMediaIds(
|
||||
trakt=int(ids_dict.get("trakt", 0) or 0),
|
||||
tmdb=int(ids_dict.get("tmdb", 0) or 0),
|
||||
imdb=str(ids_dict.get("imdb", "") or ""),
|
||||
slug=str(ids_dict.get("slug", "") or ""),
|
||||
tvdb=int(ids_dict.get("tvdb", 0) or 0),
|
||||
)
|
||||
|
||||
def _parse_list_items(self, items: list) -> list[TraktItem]:
|
||||
result: list[TraktItem] = []
|
||||
for entry in items:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
item_type = entry.get("type", "")
|
||||
media = entry.get(item_type) or entry.get("movie") or entry.get("show") or {}
|
||||
if not isinstance(media, dict):
|
||||
continue
|
||||
ids = self._parse_ids(media.get("ids") or {})
|
||||
result.append(TraktItem(
|
||||
title=str(media.get("title", "") or ""),
|
||||
year=int(media.get("year", 0) or 0),
|
||||
media_type=item_type,
|
||||
ids=ids,
|
||||
))
|
||||
return result
|
||||
|
||||
def _parse_history_items(self, items: list) -> list[TraktItem]:
|
||||
result: list[TraktItem] = []
|
||||
for entry in items:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
item_type = entry.get("type", "")
|
||||
watched_at = str(entry.get("watched_at", "") or "")
|
||||
|
||||
if item_type == "episode":
|
||||
show = entry.get("show") or {}
|
||||
ep = entry.get("episode") or {}
|
||||
ids = self._parse_ids((show.get("ids") or {}))
|
||||
result.append(TraktItem(
|
||||
title=str(show.get("title", "") or ""),
|
||||
year=int(show.get("year", 0) or 0),
|
||||
media_type="episode",
|
||||
ids=ids,
|
||||
season=int(ep.get("season", 0) or 0),
|
||||
episode=int(ep.get("number", 0) or 0),
|
||||
watched_at=watched_at,
|
||||
))
|
||||
else:
|
||||
media = entry.get("movie") or entry.get("show") or {}
|
||||
ids = self._parse_ids(media.get("ids") or {})
|
||||
result.append(TraktItem(
|
||||
title=str(media.get("title", "") or ""),
|
||||
year=int(media.get("year", 0) or 0),
|
||||
media_type=item_type,
|
||||
ids=ids,
|
||||
watched_at=watched_at,
|
||||
))
|
||||
return result
|
||||
738
addon/core/updater.py
Normal file
738
addon/core/updater.py
Normal file
@@ -0,0 +1,738 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update- und Versionsverwaltung fuer ViewIT.
|
||||
|
||||
Dieses Modul kuemmert sich um:
|
||||
- Update-Kanaele (Main, Nightly, Dev, Custom)
|
||||
- Versions-Abfrage und -Installation aus Repositories
|
||||
- Changelog-Abruf
|
||||
- Repository-Quellen-Verwaltung
|
||||
- ResolveURL Auto-Installation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
try: # pragma: no cover - Kodi runtime
|
||||
import xbmc # type: ignore[import-not-found]
|
||||
import xbmcaddon # type: ignore[import-not-found]
|
||||
import xbmcgui # type: ignore[import-not-found]
|
||||
import xbmcvfs # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - allow importing outside Kodi
|
||||
xbmc = None
|
||||
xbmcaddon = None
|
||||
xbmcgui = None
|
||||
xbmcvfs = None
|
||||
|
||||
from plugin_helpers import show_error, show_notification
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konstanten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UPDATE_CHANNEL_MAIN = 0
|
||||
UPDATE_CHANNEL_NIGHTLY = 1
|
||||
UPDATE_CHANNEL_CUSTOM = 2
|
||||
UPDATE_CHANNEL_DEV = 3
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsfunktionen (Settings-Zugriff)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Diese Callbacks werden von default.py einmal gesetzt, damit updater.py
|
||||
# keine zirkulaeren Abhaengigkeiten hat.
|
||||
_get_setting_string = None
|
||||
_get_setting_bool = None
|
||||
_get_setting_int = None
|
||||
_set_setting_string = None
|
||||
_get_addon = None
|
||||
_log_fn = None
|
||||
|
||||
|
||||
def init(
|
||||
*,
|
||||
get_setting_string,
|
||||
get_setting_bool,
|
||||
get_setting_int,
|
||||
set_setting_string,
|
||||
get_addon,
|
||||
log_fn,
|
||||
) -> None:
|
||||
"""Initialisiert Callbacks fuer Settings-Zugriff."""
|
||||
global _get_setting_string, _get_setting_bool, _get_setting_int
|
||||
global _set_setting_string, _get_addon, _log_fn
|
||||
_get_setting_string = get_setting_string
|
||||
_get_setting_bool = get_setting_bool
|
||||
_get_setting_int = get_setting_int
|
||||
_set_setting_string = set_setting_string
|
||||
_get_addon = get_addon
|
||||
_log_fn = log_fn
|
||||
|
||||
|
||||
def _log(message: str, level: int = 1) -> None:
|
||||
if _log_fn is not None:
|
||||
_log_fn(message, level)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL-Normalisierung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_update_info_url(raw: str) -> str:
|
||||
value = str(raw or "").strip()
|
||||
default = "http://127.0.0.1:8080/repo/addons.xml"
|
||||
if not value:
|
||||
return default
|
||||
if value.endswith("/addons.xml"):
|
||||
return value
|
||||
return value.rstrip("/") + "/addons.xml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update-Kanaele
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def selected_update_channel() -> int:
|
||||
channel = _get_setting_int("update_channel", default=UPDATE_CHANNEL_MAIN)
|
||||
if channel not in {UPDATE_CHANNEL_MAIN, UPDATE_CHANNEL_NIGHTLY, UPDATE_CHANNEL_CUSTOM, UPDATE_CHANNEL_DEV}:
|
||||
return UPDATE_CHANNEL_MAIN
|
||||
return channel
|
||||
|
||||
|
||||
def channel_label(channel: int) -> str:
|
||||
if channel == UPDATE_CHANNEL_NIGHTLY:
|
||||
return "Nightly"
|
||||
if channel == UPDATE_CHANNEL_DEV:
|
||||
return "Dev"
|
||||
if channel == UPDATE_CHANNEL_CUSTOM:
|
||||
return "Custom"
|
||||
return "Main"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Versionierung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 is_dev_version(version: str) -> bool:
|
||||
return bool(re.match(r"^\d+\.\d+\.\d+-dev$", 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)]
|
||||
if channel == UPDATE_CHANNEL_DEV:
|
||||
return [v for v in versions if is_dev_version(v)]
|
||||
return list(versions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP-Helfer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repo-Abfragen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 _extract_repo_identity(info_url: str) -> tuple[str, str, str, str] | None:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Changelog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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:
|
||||
version_text = str(version or "").strip().casefold()
|
||||
if version_text.endswith("-dev"):
|
||||
url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/dev/CHANGELOG-DEV.md"
|
||||
elif version_text.endswith("-nightly"):
|
||||
url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/nightly/CHANGELOG-NIGHTLY.md"
|
||||
elif channel == UPDATE_CHANNEL_DEV:
|
||||
url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/dev/CHANGELOG-DEV.md"
|
||||
elif 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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}", 2)
|
||||
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):
|
||||
_log(f"Sicherheitswarnung: Verdaechtiger ZIP-Eintrag abgelehnt: {name!r}", 2)
|
||||
return False
|
||||
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}", 2)
|
||||
return False
|
||||
|
||||
builtin = getattr(xbmc, "executebuiltin", None) if xbmc else 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"
|
||||
|
||||
builtin = getattr(xbmc, "executebuiltin", None) if xbmc else 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}", 2)
|
||||
|
||||
return install_addon_version_manual(info_url, version)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installierte Version / Addon-Pruefung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 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 xbmc else 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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repository-Quellen-Verwaltung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def repo_addon_xml_path() -> str:
|
||||
if xbmcvfs is None:
|
||||
return ""
|
||||
try:
|
||||
return xbmcvfs.translatePath("special://home/addons/repository.viewit/addon.xml")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def update_repository_source(info_url: str) -> bool:
|
||||
path = repo_addon_xml_path()
|
||||
if not path:
|
||||
return False
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
dir_node = root.find(".//dir")
|
||||
if dir_node is None:
|
||||
return False
|
||||
info = dir_node.find("info")
|
||||
checksum = dir_node.find("checksum")
|
||||
datadir = dir_node.find("datadir")
|
||||
if info is None or checksum is None or datadir is None:
|
||||
return False
|
||||
base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/")
|
||||
info.text = info_url
|
||||
checksum.text = f"{base}/addons.xml.md5"
|
||||
datadir.text = f"{base}/"
|
||||
tree.write(path, encoding="utf-8", xml_declaration=True)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_log(f"Repository-URL konnte nicht gesetzt werden: {exc}", 2)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResolveURL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 xbmc else 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}", 2)
|
||||
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 and xbmcgui is not None:
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update-Kanal anwenden / Sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_update_info_url() -> str:
|
||||
channel = selected_update_channel()
|
||||
if channel == UPDATE_CHANNEL_NIGHTLY:
|
||||
raw = _get_setting_string("update_repo_url_nightly")
|
||||
elif channel == UPDATE_CHANNEL_DEV:
|
||||
raw = _get_setting_string("update_repo_url_dev")
|
||||
elif channel == UPDATE_CHANNEL_CUSTOM:
|
||||
raw = _get_setting_string("update_repo_url")
|
||||
else:
|
||||
raw = _get_setting_string("update_repo_url_main")
|
||||
return normalize_update_info_url(raw)
|
||||
|
||||
|
||||
def sync_update_channel_status_settings() -> None:
|
||||
channel = selected_update_channel()
|
||||
selected_info_url = resolve_update_info_url()
|
||||
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_selected", available_selected)
|
||||
|
||||
|
||||
def sync_update_version_settings() -> None:
|
||||
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_installed_version", addon_version)
|
||||
sync_resolveurl_status_setting()
|
||||
sync_update_channel_status_settings()
|
||||
|
||||
|
||||
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)
|
||||
show_notification(
|
||||
"Updates",
|
||||
"Kanal gespeichert, aber repository.viewit nicht gefunden.",
|
||||
icon=warning_icon,
|
||||
milliseconds=5000,
|
||||
)
|
||||
elif target_version == "-":
|
||||
show_error("Updates", "Kanal angewendet, aber keine Version im Kanal gefunden.", milliseconds=5000)
|
||||
elif not install_result:
|
||||
show_error(
|
||||
"Updates",
|
||||
f"Kanal angewendet, Installation von {target_version} fehlgeschlagen.",
|
||||
milliseconds=5000,
|
||||
)
|
||||
elif target_version == installed_version:
|
||||
show_notification(
|
||||
"Updates",
|
||||
f"Kanal angewendet: {channel_label(selected_update_channel())} ({target_version} bereits installiert)",
|
||||
milliseconds=4500,
|
||||
)
|
||||
else:
|
||||
show_notification(
|
||||
"Updates",
|
||||
f"Kanal angewendet: {channel_label(selected_update_channel())} -> {target_version} installiert",
|
||||
milliseconds=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:
|
||||
apply_update_channel(silent=True)
|
||||
if not silent:
|
||||
builtin = getattr(xbmc, "executebuiltin", None)
|
||||
if callable(builtin):
|
||||
builtin("ActivateWindow(addonbrowser,addons://updates/)")
|
||||
if not silent:
|
||||
show_notification("Updates", "Update-Check gestartet.", milliseconds=4000)
|
||||
except Exception as exc:
|
||||
_log(f"Update-Pruefung fehlgeschlagen: {exc}", 2)
|
||||
if not silent:
|
||||
show_error("Updates", "Update-Check fehlgeschlagen.", milliseconds=4000)
|
||||
|
||||
|
||||
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:
|
||||
show_error("Updates", "Keine Versionen im Repo gefunden.", milliseconds=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:
|
||||
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
|
||||
|
||||
show_notification("Updates", f"Installation gestartet: {version}", milliseconds=2500)
|
||||
ok = install_addon_version(info_url, version)
|
||||
if ok:
|
||||
sync_update_version_settings()
|
||||
show_notification("Updates", f"Version {version} installiert.", milliseconds=4000)
|
||||
else:
|
||||
show_error("Updates", f"Installation von {version} fehlgeschlagen.", milliseconds=4500)
|
||||
|
||||
|
||||
def maybe_run_auto_update_check(action: str | None) -> None:
|
||||
action = (action or "").strip()
|
||||
if action:
|
||||
return
|
||||
if not _get_setting_bool("auto_update_enabled", default=False):
|
||||
return
|
||||
now = int(time.time())
|
||||
last = _get_setting_int("auto_update_last_ts", default=0)
|
||||
if last > 0 and (now - last) < AUTO_UPDATE_INTERVAL_SEC:
|
||||
return
|
||||
_set_setting_string("auto_update_last_ts", str(now))
|
||||
run_update_check(silent=True)
|
||||
Reference in New Issue
Block a user