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:
2026-03-01 18:23:45 +01:00
parent 73f07d20b4
commit 7b60b00c8b
36 changed files with 4765 additions and 672 deletions

341
addon/core/gui.py Normal file
View 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 ""