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:
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 {}
|
||||
Reference in New Issue
Block a user