5505 lines
213 KiB
Python
5505 lines
213 KiB
Python
#!/usr/bin/env python3
|
||
"""ViewIt Kodi-Addon Einstiegspunkt.
|
||
|
||
Dieses Modul ist der Router fuer die Kodi-Navigation: es rendert Menues,
|
||
ruft Plugin-Implementierungen auf und startet die Wiedergabe.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import atexit
|
||
from contextlib import contextmanager
|
||
from datetime import datetime
|
||
import importlib.util
|
||
import inspect
|
||
import json
|
||
import io
|
||
import os
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
import xml.etree.ElementTree as ET
|
||
import zipfile
|
||
from pathlib import Path
|
||
from types import ModuleType
|
||
from urllib.parse import parse_qs, urlencode, urlparse
|
||
from urllib.error import URLError
|
||
from urllib.request import Request, urlopen
|
||
|
||
|
||
def _ensure_windows_selector_policy() -> None:
|
||
"""Erzwingt unter Windows einen Selector-Loop (thread-kompatibel in Kodi)."""
|
||
if not sys.platform.startswith("win"):
|
||
return
|
||
try:
|
||
current = asyncio.get_event_loop_policy()
|
||
if current.__class__.__name__ == "WindowsSelectorEventLoopPolicy":
|
||
return
|
||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||
except Exception:
|
||
# Fallback: Wenn die Policy nicht verfügbar ist, arbeitet der Code mit Default-Policy weiter.
|
||
return
|
||
|
||
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 xbmcplugin # type: ignore[import-not-found]
|
||
import xbmcvfs # type: ignore[import-not-found]
|
||
except ImportError: # pragma: no cover - allow importing outside Kodi (e.g. linting)
|
||
xbmc = None
|
||
xbmcaddon = None
|
||
xbmcgui = None
|
||
xbmcplugin = None
|
||
xbmcvfs = None
|
||
|
||
class _XbmcStub:
|
||
LOGDEBUG = 0
|
||
LOGINFO = 1
|
||
LOGWARNING = 2
|
||
|
||
@staticmethod
|
||
def log(message: str, level: int = 1) -> None:
|
||
print(f"[KodiStub:{level}] {message}")
|
||
|
||
class Player:
|
||
def play(self, item: str, listitem: object | None = None) -> None:
|
||
print(f"[KodiStub] play: {item}")
|
||
|
||
class _XbmcGuiStub:
|
||
INPUT_ALPHANUM = 0
|
||
NOTIFICATION_INFO = 0
|
||
|
||
class Dialog:
|
||
def input(self, heading: str, type: int = 0) -> str:
|
||
raise RuntimeError("xbmcgui ist nicht verfuegbar (KodiStub).")
|
||
|
||
def select(self, heading: str, options: list[str]) -> int:
|
||
raise RuntimeError("xbmcgui ist nicht verfuegbar (KodiStub).")
|
||
|
||
def notification(self, heading: str, message: str, icon: int = 0, time: int = 0) -> None:
|
||
print(f"[KodiStub] notification: {heading}: {message}")
|
||
|
||
class ListItem:
|
||
def __init__(self, label: str = "", path: str = "") -> None:
|
||
self._label = label
|
||
self._path = path
|
||
|
||
def setInfo(self, type: str, infoLabels: dict[str, str]) -> None:
|
||
return
|
||
|
||
class _XbmcPluginStub:
|
||
@staticmethod
|
||
def addDirectoryItem(*, handle: int, url: str, listitem: object, isFolder: bool) -> None:
|
||
print(f"[KodiStub] addDirectoryItem: {url}")
|
||
|
||
@staticmethod
|
||
def endOfDirectory(handle: int) -> None:
|
||
print(f"[KodiStub] endOfDirectory: {handle}")
|
||
|
||
@staticmethod
|
||
def setPluginCategory(handle: int, category: str) -> None:
|
||
print(f"[KodiStub] category: {category}")
|
||
|
||
xbmc = _XbmcStub()
|
||
xbmcgui = _XbmcGuiStub()
|
||
xbmcplugin = _XbmcPluginStub()
|
||
|
||
from plugin_interface import BasisPlugin
|
||
from http_session_pool import close_all_sessions
|
||
from plugin_helpers import normalize_resolved_stream_url
|
||
from metadata_utils import (
|
||
collect_plugin_metadata as _collect_plugin_metadata,
|
||
merge_metadata as _merge_metadata,
|
||
metadata_policy as _metadata_policy_impl,
|
||
needs_tmdb as _needs_tmdb,
|
||
)
|
||
from tmdb import TmdbCastMember, TmdbExternalIds, fetch_external_ids, fetch_tv_episode_credits, lookup_movie, lookup_tv_season, lookup_tv_season_summary, lookup_tv_show
|
||
from core.router import Router
|
||
|
||
_router = Router()
|
||
|
||
PLUGIN_DIR = Path(__file__).with_name("plugins")
|
||
_PLUGIN_CACHE: dict[str, BasisPlugin] | None = None
|
||
_TMDB_CACHE: dict[str, tuple[dict[str, str], dict[str, str]]] = {}
|
||
_TMDB_CAST_CACHE: dict[str, list[TmdbCastMember]] = {}
|
||
_TMDB_ID_CACHE: dict[str, int] = {}
|
||
_IMDB_ID_CACHE: dict[str, str] = {}
|
||
_MEDIA_TYPE_CACHE: dict[str, str] = {}
|
||
_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]] = {}
|
||
_TRAKT_SEASON_META_CACHE: dict = {}
|
||
_TMDB_LOG_PATH: str | None = None
|
||
_GENRE_TITLES_CACHE: dict[tuple[str, str], list[str]] = {}
|
||
_ADDON_INSTANCE = None
|
||
_PLAYSTATE_CACHE: dict[str, dict[str, object]] | None = None
|
||
_PLAYSTATE_LOCK = threading.RLock()
|
||
_TMDB_LOCK = threading.RLock()
|
||
_PLUGIN_CACHE_LOCK = threading.Lock()
|
||
_GENRE_TITLES_CACHE_LOCK = threading.Lock()
|
||
_TRAKT_WATCHED_CACHE: dict[str, set[tuple[int, int]]] = {}
|
||
_TRAKT_WATCHED_CACHE_TS: float = 0.0
|
||
_TRAKT_WATCHED_CACHE_TTL: int = 300 # 5 Minuten
|
||
_TRAKT_WATCHED_CACHE_LOCK = threading.RLock()
|
||
_TRAKT_PLUGIN_MATCH_CACHE: dict[str, tuple[str, str] | None] = {}
|
||
_TRAKT_PLUGIN_MATCH_CACHE_TS: float = 0.0
|
||
_TRAKT_PLUGIN_MATCH_CACHE_TTL: int = 300 # 5 Minuten
|
||
_TRAKT_PLUGIN_MATCH_LOCK = threading.RLock()
|
||
_CACHE_MAXSIZE = 500
|
||
WATCHED_THRESHOLD = 0.9
|
||
POPULAR_MENU_LABEL = "Haeufig gesehen"
|
||
LATEST_MENU_LABEL = "Neuste Titel"
|
||
LIST_PAGE_SIZE = 20
|
||
|
||
atexit.register(close_all_sessions)
|
||
|
||
|
||
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
|
||
if len(cache) > _CACHE_MAXSIZE:
|
||
# Python 3.7+: dicts sind insertion-ordered → aelteste Haelfte entfernen
|
||
excess = len(cache) - _CACHE_MAXSIZE // 2
|
||
for k in list(cache.keys())[:excess]:
|
||
del cache[k]
|
||
|
||
|
||
def _fetch_and_cache_imdb_id(title_key: str, tmdb_id: int, kind: str) -> str:
|
||
"""Holt die IMDb-ID via TMDB external_ids und cached sie."""
|
||
cached = _tmdb_cache_get(_IMDB_ID_CACHE, title_key)
|
||
if cached is not None:
|
||
return cached
|
||
api_key = _get_setting_string("tmdb_api_key").strip()
|
||
if not api_key or api_key == "None" or not tmdb_id:
|
||
return ""
|
||
ext = fetch_external_ids(kind=kind, tmdb_id=tmdb_id, api_key=api_key)
|
||
imdb_id = ext.imdb_id if ext else ""
|
||
_tmdb_cache_set(_IMDB_ID_CACHE, title_key, imdb_id)
|
||
return imdb_id
|
||
|
||
|
||
def _set_trakt_ids_property(title: str, tmdb_id: int, imdb_id: str = "") -> None:
|
||
"""Setzt script.trakt.ids als Window Property fuer script.trakt-Kompatibilitaet."""
|
||
if not tmdb_id:
|
||
return
|
||
ids: dict[str, object] = {"tmdb": tmdb_id}
|
||
if imdb_id:
|
||
ids["imdb"] = imdb_id
|
||
try:
|
||
window = xbmcgui.Window(10000)
|
||
window.setProperty("script.trakt.ids", json.dumps(ids))
|
||
_log(f"script.trakt.ids gesetzt: {ids}", xbmc.LOGDEBUG)
|
||
except Exception as exc:
|
||
_log(f"script.trakt.ids setzen fehlgeschlagen: {exc}", xbmc.LOGDEBUG)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Trakt-Helfer
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _trakt_load_token():
|
||
"""Laedt den gespeicherten Trakt-Token aus den Addon-Settings."""
|
||
access = _get_setting_string("trakt_access_token").strip()
|
||
refresh = _get_setting_string("trakt_refresh_token").strip()
|
||
expires = _get_setting_string("trakt_token_expires").strip()
|
||
if not access:
|
||
return None
|
||
from core.trakt import TraktToken
|
||
return TraktToken(
|
||
access_token=access, refresh_token=refresh,
|
||
expires_at=int(expires or "0"), created_at=0,
|
||
)
|
||
|
||
|
||
def _trakt_save_token(token) -> None:
|
||
"""Speichert den Trakt-Token in den Addon-Settings."""
|
||
addon = _get_addon()
|
||
addon.setSetting("trakt_access_token", token.access_token)
|
||
addon.setSetting("trakt_refresh_token", token.refresh_token)
|
||
addon.setSetting("trakt_token_expires", str(token.expires_at))
|
||
|
||
|
||
TRAKT_CLIENT_ID = "5f1a46be11faa2ef286d6a5d4fbdcdfe3b19c87d3799c11af8cf25dae5b802e9"
|
||
TRAKT_CLIENT_SECRET = "7b694c47c13565197c3549c7467e92999f36fb2d118f7c185736ec960af22405"
|
||
|
||
|
||
def _trakt_get_client():
|
||
"""Erstellt einen TraktClient mit den fest hinterlegten Credentials."""
|
||
from core.trakt import TraktClient
|
||
return TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG))
|
||
|
||
|
||
def _trakt_get_valid_token() -> str:
|
||
"""Gibt einen gueltigen Access-Token zurueck, refresht ggf. automatisch."""
|
||
token = _trakt_load_token()
|
||
if not token:
|
||
return ""
|
||
if token.expires_at > 0 and time.time() > token.expires_at - 86400:
|
||
client = _trakt_get_client()
|
||
if client:
|
||
new_token = client.refresh_token(token.refresh_token)
|
||
if new_token:
|
||
_trakt_save_token(new_token)
|
||
return new_token.access_token
|
||
return token.access_token
|
||
|
||
|
||
def _trakt_find_in_plugins(title: str) -> tuple[str, str] | None:
|
||
"""Sucht einen Trakt-Titel in allen verfuegbaren Plugins (casefold-Vergleich).
|
||
|
||
Gibt (plugin_name, matched_title) zurueck oder None bei keinem Treffer.
|
||
Ergebnisse werden 5 Minuten gecacht (inkl. None-Misses).
|
||
"""
|
||
global _TRAKT_PLUGIN_MATCH_CACHE_TS
|
||
if not title:
|
||
return None
|
||
title_cf = title.casefold()
|
||
now = time.time()
|
||
with _TRAKT_PLUGIN_MATCH_LOCK:
|
||
if now - _TRAKT_PLUGIN_MATCH_CACHE_TS < _TRAKT_PLUGIN_MATCH_CACHE_TTL:
|
||
if title_cf in _TRAKT_PLUGIN_MATCH_CACHE:
|
||
return _TRAKT_PLUGIN_MATCH_CACHE[title_cf]
|
||
result: tuple[str, str] | None = None
|
||
for plugin_name, plugin in _discover_plugins().items():
|
||
try:
|
||
coro = _call_plugin_search(plugin, title)
|
||
results = _run_async(coro) if inspect.iscoroutine(coro) else (coro or [])
|
||
for r in (results or []):
|
||
if str(r).strip().casefold() == title_cf:
|
||
result = (plugin_name, str(r).strip())
|
||
break
|
||
except Exception:
|
||
pass
|
||
if result:
|
||
break
|
||
with _TRAKT_PLUGIN_MATCH_LOCK:
|
||
_TRAKT_PLUGIN_MATCH_CACHE[title_cf] = result
|
||
_TRAKT_PLUGIN_MATCH_CACHE_TS = now
|
||
return result
|
||
|
||
|
||
def _trakt_watched_set(title: str) -> set[tuple[int, int]]:
|
||
"""Liefert die Menge der gesehenen (season, episode)-Tupel fuer einen Titel.
|
||
|
||
Ergebnis wird _TRAKT_WATCHED_CACHE_TTL Sekunden gecacht.
|
||
Gibt ein leeres Set zurueck wenn Trakt nicht aktiviert oder kein Token vorhanden.
|
||
"""
|
||
global _TRAKT_WATCHED_CACHE_TS
|
||
if not _get_setting_bool("trakt_enabled", default=False):
|
||
return set()
|
||
token = _trakt_get_valid_token()
|
||
client = _trakt_get_client()
|
||
if not token or not client:
|
||
return set()
|
||
title_cf = title.casefold()
|
||
now = time.time()
|
||
with _TRAKT_WATCHED_CACHE_LOCK:
|
||
if now - _TRAKT_WATCHED_CACHE_TS < _TRAKT_WATCHED_CACHE_TTL:
|
||
if title_cf in _TRAKT_WATCHED_CACHE:
|
||
return set(_TRAKT_WATCHED_CACHE[title_cf]) # Kopie zurueckgeben
|
||
try:
|
||
history = client.get_history(token, media_type="episodes", limit=200)
|
||
except Exception:
|
||
return set()
|
||
watched: set[tuple[int, int]] = set()
|
||
for item in history:
|
||
if item.title.casefold() == title_cf:
|
||
watched.add((item.season, item.episode))
|
||
with _TRAKT_WATCHED_CACHE_LOCK:
|
||
_TRAKT_WATCHED_CACHE[title_cf] = watched
|
||
_TRAKT_WATCHED_CACHE_TS = now
|
||
return set(watched)
|
||
|
||
|
||
def _tmdb_prefetch_concurrency() -> int:
|
||
"""Max number of concurrent TMDB lookups when prefetching metadata for lists."""
|
||
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:
|
||
return _get_setting_bool("tmdb_enabled", default=True)
|
||
|
||
|
||
def _log(message: str, level: int = xbmc.LOGINFO) -> None:
|
||
xbmc.log(f"[ViewIt] {message}", level)
|
||
|
||
|
||
def _busy_open() -> None:
|
||
try: # pragma: no cover - Kodi runtime
|
||
if xbmc is not None and hasattr(xbmc, "executebuiltin"):
|
||
xbmc.executebuiltin("ActivateWindow(busydialognocancel)")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _busy_close() -> None:
|
||
try: # pragma: no cover - Kodi runtime
|
||
if xbmc is not None and hasattr(xbmc, "executebuiltin"):
|
||
xbmc.executebuiltin("Dialog.Close(busydialognocancel)")
|
||
xbmc.executebuiltin("Dialog.Close(busydialog)")
|
||
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")
|
||
|
||
|
||
@contextmanager
|
||
def _progress_dialog(heading: str, message: str = ""):
|
||
"""Zeigt einen Fortschrittsdialog in Kodi und liefert eine Update-Funktion."""
|
||
dialog = None
|
||
try: # pragma: no cover - Kodi runtime
|
||
if xbmcgui is not None and hasattr(xbmcgui, "DialogProgress"):
|
||
dialog = xbmcgui.DialogProgress()
|
||
dialog.create(heading, message)
|
||
except Exception:
|
||
dialog = None
|
||
|
||
def _update(percent: int, text: str = "") -> bool:
|
||
if dialog is None:
|
||
return False
|
||
percent = max(0, min(100, int(percent)))
|
||
try: # Kodi Matrix/Nexus
|
||
dialog.update(percent, text)
|
||
except TypeError:
|
||
try: # Kodi Leia fallback
|
||
dialog.update(percent, text, "", "")
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
try:
|
||
return bool(dialog.iscanceled())
|
||
except Exception:
|
||
return False
|
||
|
||
try:
|
||
yield _update
|
||
finally:
|
||
if dialog is not None:
|
||
try:
|
||
dialog.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _run_with_progress(heading: str, message: str, loader):
|
||
"""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 _method_accepts_kwarg(method: object, kwarg_name: str) -> bool:
|
||
if not callable(method):
|
||
return False
|
||
try:
|
||
signature = inspect.signature(method)
|
||
except Exception:
|
||
return False
|
||
for param in signature.parameters.values():
|
||
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
||
return True
|
||
if param.name == kwarg_name and param.kind in (
|
||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||
inspect.Parameter.KEYWORD_ONLY,
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _call_plugin_search(plugin: BasisPlugin, query: str, *, progress_callback=None):
|
||
method = getattr(plugin, "search_titles", None)
|
||
if not callable(method):
|
||
raise RuntimeError("Plugin hat keine gueltige search_titles Methode.")
|
||
if progress_callback is not None and _method_accepts_kwarg(method, "progress_callback"):
|
||
return method(query, progress_callback=progress_callback)
|
||
return method(query)
|
||
|
||
|
||
def _get_handle() -> int:
|
||
return int(sys.argv[1]) if len(sys.argv) > 1 else -1
|
||
|
||
|
||
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: # pragma: no cover - Kodi runtime
|
||
setter = getattr(xbmcplugin, "setContent", None)
|
||
if callable(setter):
|
||
setter(handle, content)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
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 _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, object]]:
|
||
return {}
|
||
|
||
|
||
def _save_playstate(state: dict[str, dict[str, object]]) -> None:
|
||
return
|
||
|
||
|
||
def _get_playstate(key: str) -> dict[str, object]:
|
||
return {}
|
||
|
||
|
||
def _set_playstate(key: str, value: dict[str, object]) -> None:
|
||
return
|
||
|
||
|
||
def _apply_playstate_to_info(info_labels: dict[str, object], playstate: dict[str, object]) -> dict[str, object]:
|
||
return dict(info_labels or {})
|
||
|
||
|
||
def _label_with_playstate(label: str, playstate: dict[str, object]) -> str:
|
||
return label
|
||
|
||
|
||
def _title_playstate(plugin_name: str, title: str) -> dict[str, object]:
|
||
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, object]:
|
||
return _get_playstate(_playstate_key(plugin_name=plugin_name, title=title, season=season, episode=""))
|
||
|
||
|
||
def _get_setting_string(setting_id: str) -> str:
|
||
if xbmcaddon is None:
|
||
return ""
|
||
addon = _get_addon()
|
||
if addon is None:
|
||
return ""
|
||
getter = getattr(addon, "getSettingString", None)
|
||
if callable(getter):
|
||
try:
|
||
return str(getter(setting_id) or "")
|
||
except TypeError:
|
||
return ""
|
||
getter = getattr(addon, "getSetting", None)
|
||
if callable(getter):
|
||
try:
|
||
return str(getter(setting_id) or "")
|
||
except TypeError:
|
||
return ""
|
||
return ""
|
||
|
||
|
||
def _get_setting_bool(setting_id: str, *, default: bool = False) -> bool:
|
||
if xbmcaddon is None:
|
||
return default
|
||
addon = _get_addon()
|
||
if addon is None:
|
||
return default
|
||
getter = getattr(addon, "getSettingBool", None)
|
||
if callable(getter):
|
||
# Kodi kann für unbekannte Settings stillschweigend `False` liefern.
|
||
# Damit neue Settings mit `default=True` korrekt funktionieren, prüfen wir auf leeren Raw-Value.
|
||
raw_getter = getattr(addon, "getSetting", None)
|
||
if callable(raw_getter):
|
||
try:
|
||
raw = str(raw_getter(setting_id) or "").strip()
|
||
except TypeError:
|
||
raw = ""
|
||
if raw == "":
|
||
return default
|
||
try:
|
||
return bool(getter(setting_id))
|
||
except TypeError:
|
||
return default
|
||
getter = getattr(addon, "getSetting", None)
|
||
if callable(getter):
|
||
try:
|
||
raw = str(getter(setting_id) or "").strip().lower()
|
||
except TypeError:
|
||
return default
|
||
if raw in {"true", "1", "yes", "on"}:
|
||
return True
|
||
if raw in {"false", "0", "no", "off"}:
|
||
return False
|
||
return default
|
||
|
||
|
||
def _get_setting_int(setting_id: str, *, default: int = 0) -> int:
|
||
if xbmcaddon is None:
|
||
return default
|
||
addon = _get_addon()
|
||
if addon is None:
|
||
return default
|
||
getter = getattr(addon, "getSettingInt", None)
|
||
if callable(getter):
|
||
raw_getter = getattr(addon, "getSetting", None)
|
||
if callable(raw_getter):
|
||
try:
|
||
raw = str(raw_getter(setting_id) or "").strip()
|
||
except TypeError:
|
||
raw = ""
|
||
if raw == "":
|
||
return default
|
||
try:
|
||
return int(getter(setting_id))
|
||
except TypeError:
|
||
return default
|
||
getter = getattr(addon, "getSetting", None)
|
||
if callable(getter):
|
||
try:
|
||
raw = str(getter(setting_id) or "").strip()
|
||
except TypeError:
|
||
return default
|
||
if raw == "":
|
||
return default
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
return default
|
||
return default
|
||
|
||
|
||
def _metadata_policy(
|
||
plugin_name: str,
|
||
plugin: BasisPlugin,
|
||
*,
|
||
allow_tmdb: bool,
|
||
) -> tuple[bool, bool, bool]:
|
||
return _metadata_policy_impl(
|
||
plugin_name,
|
||
plugin,
|
||
allow_tmdb=allow_tmdb,
|
||
get_setting_int=_get_setting_int,
|
||
)
|
||
|
||
|
||
def _tmdb_list_enabled() -> bool:
|
||
return _tmdb_enabled() and _get_setting_bool("tmdb_genre_metadata", default=False)
|
||
|
||
|
||
def _set_setting_string(setting_id: str, value: str) -> None:
|
||
if xbmcaddon is None:
|
||
return
|
||
addon = _get_addon()
|
||
if addon is None:
|
||
return
|
||
setter = getattr(addon, "setSettingString", None)
|
||
if callable(setter):
|
||
try:
|
||
setter(setting_id, str(value))
|
||
return
|
||
except TypeError:
|
||
return
|
||
setter = getattr(addon, "setSetting", None)
|
||
if callable(setter):
|
||
try:
|
||
setter(setting_id, str(value))
|
||
except TypeError:
|
||
return
|
||
|
||
|
||
def _apply_video_info(item, info_labels: dict[str, object] | None, cast: list[TmdbCastMember] | None) -> None:
|
||
"""Setzt Metadaten bevorzugt via InfoTagVideo (Kodi v20+), mit Fallback auf deprecated APIs."""
|
||
|
||
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:
|
||
title = info_labels.get("title") or ""
|
||
plot = info_labels.get("plot") or ""
|
||
mediatype = info_labels.get("mediatype") or ""
|
||
tvshowtitle = info_labels.get("tvshowtitle") or ""
|
||
season = info_labels.get("season")
|
||
episode = info_labels.get("episode")
|
||
rating = info_labels.get("rating")
|
||
votes = info_labels.get("votes")
|
||
duration = info_labels.get("duration")
|
||
playcount = info_labels.get("playcount")
|
||
resume_position = info_labels.get("resume_position")
|
||
resume_total = info_labels.get("resume_total")
|
||
|
||
setter = getattr(tag, "setTitle", None)
|
||
if callable(setter) and title:
|
||
setter(str(title))
|
||
setter = getattr(tag, "setPlot", None)
|
||
if callable(setter) and plot:
|
||
setter(str(plot))
|
||
setter = getattr(tag, "setMediaType", None)
|
||
if callable(setter) and mediatype:
|
||
setter(str(mediatype))
|
||
setter = getattr(tag, "setTvShowTitle", None)
|
||
if callable(setter) and tvshowtitle:
|
||
setter(str(tvshowtitle))
|
||
setter = getattr(tag, "setSeason", None)
|
||
if callable(setter) and season not in (None, "", 0, "0"):
|
||
setter(int(season)) # type: ignore[arg-type]
|
||
setter = getattr(tag, "setEpisode", None)
|
||
if callable(setter) and episode not in (None, "", 0, "0"):
|
||
setter(int(episode)) # type: ignore[arg-type]
|
||
|
||
if rating not in (None, "", 0, "0"):
|
||
try:
|
||
rating_f = float(rating) # type: ignore[arg-type]
|
||
except Exception:
|
||
rating_f = 0.0
|
||
if rating_f:
|
||
set_rating = getattr(tag, "setRating", None)
|
||
if callable(set_rating):
|
||
try:
|
||
if votes not in (None, "", 0, "0"):
|
||
set_rating(rating_f, int(votes), "tmdb") # type: ignore[misc]
|
||
else:
|
||
set_rating(rating_f) # type: ignore[misc]
|
||
except Exception:
|
||
try:
|
||
set_rating(rating_f, int(votes or 0), "tmdb", True) # type: ignore[misc]
|
||
except Exception:
|
||
pass
|
||
|
||
if duration not in (None, "", 0, "0"):
|
||
try:
|
||
duration_i = int(duration) # type: ignore[arg-type]
|
||
except Exception:
|
||
duration_i = 0
|
||
if duration_i:
|
||
set_duration = getattr(tag, "setDuration", None)
|
||
if callable(set_duration):
|
||
try:
|
||
set_duration(duration_i)
|
||
except Exception:
|
||
pass
|
||
|
||
if playcount not in (None, "", 0, "0"):
|
||
try:
|
||
playcount_i = int(playcount) # type: ignore[arg-type]
|
||
except Exception:
|
||
playcount_i = 0
|
||
if playcount_i:
|
||
set_playcount = getattr(tag, "setPlaycount", None)
|
||
if callable(set_playcount):
|
||
try:
|
||
set_playcount(playcount_i)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
pos = int(resume_position) if resume_position is not None else 0
|
||
tot = int(resume_total) if resume_total is not None else 0
|
||
except Exception:
|
||
pos, tot = 0, 0
|
||
if pos > 0 and tot > 0:
|
||
set_resume = getattr(tag, "setResumePoint", None)
|
||
if callable(set_resume):
|
||
try:
|
||
set_resume(pos, tot)
|
||
except Exception:
|
||
try:
|
||
set_resume(pos) # type: ignore[misc]
|
||
except Exception:
|
||
pass
|
||
|
||
if cast:
|
||
set_cast = getattr(tag, "setCast", None)
|
||
actor_cls = getattr(xbmc, "Actor", None)
|
||
if callable(set_cast) and actor_cls is not None:
|
||
actors = []
|
||
for index, member in enumerate(cast[:30]):
|
||
try:
|
||
actors.append(actor_cls(member.name, member.role, index, member.thumb))
|
||
except Exception:
|
||
try:
|
||
actors.append(actor_cls(member.name, member.role))
|
||
except Exception:
|
||
continue
|
||
try:
|
||
set_cast(actors)
|
||
except Exception:
|
||
pass
|
||
elif callable(set_cast):
|
||
cast_dicts = [
|
||
{"name": m.name, "role": m.role, "thumbnail": m.thumb}
|
||
for m in cast[:30]
|
||
if m.name
|
||
]
|
||
try:
|
||
set_cast(cast_dicts)
|
||
except Exception:
|
||
pass
|
||
|
||
return
|
||
except Exception:
|
||
# Fallback below
|
||
pass
|
||
|
||
# Deprecated fallback for older Kodi.
|
||
try:
|
||
item.setInfo("video", info_labels) # type: ignore[arg-type]
|
||
except Exception:
|
||
pass
|
||
if cast:
|
||
set_cast = getattr(item, "setCast", None)
|
||
if callable(set_cast):
|
||
try:
|
||
set_cast([m.name for m in cast[:30] if m.name])
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
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(__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")
|
||
handle.write(line)
|
||
handle.close()
|
||
except Exception:
|
||
return
|
||
|
||
|
||
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 wird nicht in _TMDB_CACHE gehalten (weil es ListItem.setCast betrifft), daher separat cachen:
|
||
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 and api_key != "None":
|
||
try:
|
||
log_fn = _tmdb_file_log if (log_requests or log_responses) else None
|
||
# Einige Plugins liefern Titel wie "… – Der Film". Für TMDB ist oft der Basistitel besser.
|
||
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}", xbmc.LOGDEBUG)
|
||
meta = None
|
||
if meta:
|
||
tmdb_id = int(getattr(meta, "tmdb_id", 0) or 0)
|
||
if tmdb_id:
|
||
_tmdb_cache_set(_TMDB_ID_CACHE, title_key, tmdb_id)
|
||
if is_tv:
|
||
_tmdb_cache_set(_MEDIA_TYPE_CACHE, title_key, "tv")
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
else:
|
||
_tmdb_cache_set(_MEDIA_TYPE_CACHE, title_key, "movie")
|
||
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:
|
||
# Kodi akzeptiert je nach Version float oder string; wir bleiben bei strings wie im restlichen Code.
|
||
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 _trakt_episode_labels_and_art(
|
||
title=title, season_label=season_label, episode_label=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 or api_key == "None":
|
||
return _trakt_episode_labels_and_art(
|
||
title=title, season_label=season_label, episode_label=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": ep.title or 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 _trakt_episode_labels_and_art(
|
||
*, title: str, season_label: str, episode_label: str
|
||
) -> tuple[dict[str, str], dict[str, str]]:
|
||
"""Trakt-Fallback für Episoden-Metadaten wenn TMDB nicht verfügbar.
|
||
Lädt Staffel-Episodendaten per Batch (extended=full,images) und optionale
|
||
deutsche Übersetzung per Episode (Translations-Endpunkt).
|
||
"""
|
||
client = _trakt_get_client()
|
||
if not client:
|
||
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}, {}
|
||
|
||
cache_key = (title.strip().casefold(), season_number)
|
||
cached = _tmdb_cache_get(_TRAKT_SEASON_META_CACHE, cache_key)
|
||
if cached is None:
|
||
slug = client.search_show(title)
|
||
if not slug:
|
||
_tmdb_cache_set(_TRAKT_SEASON_META_CACHE, cache_key, {})
|
||
return {"title": episode_label}, {}
|
||
meta = client.lookup_tv_season(slug, season_number)
|
||
_tmdb_cache_set(_TRAKT_SEASON_META_CACHE, cache_key, {"slug": slug, "episodes": meta or {}})
|
||
cached = _tmdb_cache_get(_TRAKT_SEASON_META_CACHE, cache_key)
|
||
|
||
slug = (cached or {}).get("slug", "")
|
||
episodes: dict = (cached or {}).get("episodes", {})
|
||
ep = episodes.get(episode_number)
|
||
if not ep:
|
||
return {"title": episode_label}, {}
|
||
|
||
ep_title = ep.title or episode_label
|
||
ep_overview = ep.overview
|
||
|
||
language = _get_setting_string("tmdb_language").strip() or "de-DE"
|
||
lang_code = language[:2]
|
||
if slug and lang_code and lang_code != "en":
|
||
trans_key = (cache_key, episode_number, lang_code)
|
||
trans_cached = _tmdb_cache_get(_TRAKT_SEASON_META_CACHE, trans_key)
|
||
if trans_cached is None:
|
||
t_title, t_overview = client.get_episode_translation(slug, season_number, episode_number, lang_code)
|
||
trans_cached = {"title": t_title, "overview": t_overview}
|
||
_tmdb_cache_set(_TRAKT_SEASON_META_CACHE, trans_key, trans_cached)
|
||
if trans_cached.get("title"):
|
||
ep_title = trans_cached["title"]
|
||
if trans_cached.get("overview"):
|
||
ep_overview = trans_cached["overview"]
|
||
|
||
info: dict[str, str] = {"title": ep_title}
|
||
if ep_overview:
|
||
info["plot"] = ep_overview
|
||
if ep.runtime_minutes:
|
||
info["duration"] = str(ep.runtime_minutes * 60)
|
||
art: dict[str, str] = {}
|
||
if ep.thumb:
|
||
art["thumb"] = ep.thumb
|
||
return info, art
|
||
|
||
|
||
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 or api_key == "None":
|
||
_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 _add_directory_item(
|
||
handle: int,
|
||
label: str,
|
||
action: str,
|
||
params: dict[str, str] | None = None,
|
||
*,
|
||
is_folder: bool = True,
|
||
info_labels: dict[str, str] | None = None,
|
||
art: dict[str, str] | None = None,
|
||
cast: list[TmdbCastMember] | None = None,
|
||
context_menu: list[tuple[str, str]] | None = None,
|
||
) -> None:
|
||
"""Fuegt einen Eintrag (Folder oder Playable) in die Kodi-Liste ein."""
|
||
query: dict[str, str] = {"action": action}
|
||
if params:
|
||
query.update(params)
|
||
url = f"{sys.argv[0]}?{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
|
||
if context_menu:
|
||
try:
|
||
item.addContextMenuItems(context_menu)
|
||
except Exception:
|
||
pass
|
||
xbmcplugin.addDirectoryItem(handle=handle, url=url, listitem=item, isFolder=is_folder)
|
||
|
||
|
||
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_CHANNEL_MAIN = 0
|
||
UPDATE_CHANNEL_NIGHTLY = 1
|
||
UPDATE_CHANNEL_CUSTOM = 2
|
||
UPDATE_CHANNEL_DEV = 3
|
||
_AUTO_UPDATE_INTERVALS = [1 * 60 * 60, 6 * 60 * 60, 24 * 60 * 60] # 1h, 6h, 24h
|
||
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
|
||
YTDLP_ADDON_ID = "script.module.yt-dlp"
|
||
|
||
|
||
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_CUSTOM:
|
||
return "Custom"
|
||
if channel == UPDATE_CHANNEL_DEV:
|
||
return "Dev"
|
||
return "Main"
|
||
|
||
|
||
def _version_sort_key(version: str) -> tuple[int, ...]:
|
||
base = str(version or "").split("-", 1)[0]
|
||
parts = []
|
||
for chunk in base.split("."):
|
||
try:
|
||
parts.append(int(chunk))
|
||
except Exception:
|
||
parts.append(0)
|
||
while len(parts) < 4:
|
||
parts.append(0)
|
||
return tuple(parts[:4])
|
||
|
||
|
||
def _is_stable_version(version: str) -> bool:
|
||
return bool(re.match(r"^\d+\.\d+\.\d+(\.\d+)?$", str(version or "").strip()))
|
||
|
||
|
||
def _is_nightly_version(version: str) -> bool:
|
||
return bool(re.match(r"^\d+\.\d+\.\d+(\.\d+)?-nightly$", str(version or "").strip()))
|
||
|
||
|
||
def _is_dev_version(version: str) -> bool:
|
||
return bool(re.match(r"^\d+\.\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)
|
||
|
||
|
||
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_CUSTOM:
|
||
raw = _get_setting_string("update_repo_url")
|
||
elif channel == UPDATE_CHANNEL_DEV:
|
||
raw = _get_setting_string("update_repo_url_dev")
|
||
else:
|
||
raw = _get_setting_string("update_repo_url_main")
|
||
return _normalize_update_info_url(raw)
|
||
|
||
|
||
def _read_text_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> str:
|
||
request = Request(url, headers={"User-Agent": "ViewIT/1.0"})
|
||
response = None
|
||
try:
|
||
response = urlopen(request, timeout=timeout)
|
||
data = response.read()
|
||
finally:
|
||
if response is not None:
|
||
try:
|
||
response.close()
|
||
except Exception:
|
||
pass
|
||
return data.decode("utf-8", errors="replace")
|
||
|
||
|
||
def _extract_repo_addon_version(xml_text: str, addon_id: str = UPDATE_ADDON_ID) -> str:
|
||
try:
|
||
root = ET.fromstring(xml_text)
|
||
except Exception:
|
||
return "-"
|
||
|
||
if root.tag == "addon":
|
||
return str(root.attrib.get("version") or "-")
|
||
|
||
for node in root.findall("addon"):
|
||
if str(node.attrib.get("id") or "").strip() == addon_id:
|
||
version = str(node.attrib.get("version") or "").strip()
|
||
return version or "-"
|
||
return "-"
|
||
|
||
|
||
def _fetch_repo_addon_version(info_url: str) -> str:
|
||
url = _normalize_update_info_url(info_url)
|
||
try:
|
||
xml_text = _read_text_url(url)
|
||
except URLError:
|
||
return "-"
|
||
except Exception:
|
||
return "-"
|
||
return _extract_repo_addon_version(xml_text)
|
||
|
||
|
||
def _read_binary_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> bytes:
|
||
request = Request(url, headers={"User-Agent": "ViewIT/1.0"})
|
||
response = None
|
||
try:
|
||
response = urlopen(request, timeout=timeout)
|
||
return response.read()
|
||
finally:
|
||
if response is not None:
|
||
try:
|
||
response.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _extract_repo_identity(info_url: str) -> tuple[str, str, str, str] | None:
|
||
parsed = urlparse(str(info_url or "").strip())
|
||
parts = [part for part in parsed.path.split("/") if part]
|
||
try:
|
||
raw_idx = parts.index("raw")
|
||
except ValueError:
|
||
return None
|
||
# expected: /{owner}/{repo}/raw/branch/{branch}/addons.xml
|
||
if raw_idx < 2 or (raw_idx + 2) >= len(parts):
|
||
return None
|
||
if parts[raw_idx + 1] != "branch":
|
||
return None
|
||
owner = parts[raw_idx - 2]
|
||
repo = parts[raw_idx - 1]
|
||
branch = parts[raw_idx + 2]
|
||
scheme = parsed.scheme or "https"
|
||
host = parsed.netloc
|
||
if not owner or not repo or not branch or not host:
|
||
return None
|
||
return scheme, host, owner, repo + "|" + branch
|
||
|
||
|
||
def _fetch_repo_versions(info_url: str) -> list[str]:
|
||
identity = _extract_repo_identity(info_url)
|
||
if identity is None:
|
||
one = _fetch_repo_addon_version(info_url)
|
||
return [one] if one != "-" else []
|
||
|
||
scheme, host, owner, repo_branch = identity
|
||
repo, branch = repo_branch.split("|", 1)
|
||
api_url = f"{scheme}://{host}/api/v1/repos/{owner}/{repo}/contents/{UPDATE_ADDON_ID}?ref={branch}"
|
||
|
||
try:
|
||
payload = _read_text_url(api_url)
|
||
data = json.loads(payload)
|
||
except Exception:
|
||
one = _fetch_repo_addon_version(info_url)
|
||
return [one] if one != "-" else []
|
||
|
||
versions: list[str] = []
|
||
if isinstance(data, list):
|
||
for entry in data:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
name = str(entry.get("name") or "")
|
||
match = re.match(rf"^{re.escape(UPDATE_ADDON_ID)}-(.+)\.zip$", name)
|
||
if not match:
|
||
continue
|
||
version = match.group(1).strip()
|
||
if version:
|
||
versions.append(version)
|
||
|
||
# Zusaetzlich Gitea Releases API abfragen (aeltere Versionen)
|
||
try:
|
||
source_repo = repo.replace("ViewIT-Kodi-Repo", "ViewIT").replace("-Kodi-Repo", "")
|
||
releases_api = f"{scheme}://{host}/api/v1/repos/{owner}/{source_repo}/releases?limit=50"
|
||
releases_payload = _read_text_url(releases_api)
|
||
releases_data = json.loads(releases_payload)
|
||
if isinstance(releases_data, list):
|
||
for release in releases_data:
|
||
for asset in release.get("assets", []):
|
||
aname = str(asset.get("name") or "")
|
||
m = re.match(rf"^{re.escape(UPDATE_ADDON_ID)}-(.+)\.zip$", aname)
|
||
if m:
|
||
v = m.group(1).strip()
|
||
if v:
|
||
versions.append(v)
|
||
except Exception:
|
||
pass
|
||
|
||
unique = sorted(set(versions), key=_version_sort_key, reverse=True)
|
||
return unique
|
||
|
||
|
||
def _extract_changelog_section(changelog_text: str, version: str) -> str:
|
||
lines = changelog_text.splitlines()
|
||
wanted = (version or "").strip()
|
||
if not wanted:
|
||
return "\n".join(lines[:120]).strip()
|
||
|
||
start = -1
|
||
for idx, line in enumerate(lines):
|
||
if line.startswith("## ") and wanted in line:
|
||
start = idx
|
||
break
|
||
if start < 0:
|
||
return ""
|
||
|
||
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_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 ""
|
||
return _extract_changelog_section(text, version)
|
||
|
||
|
||
def _install_addon_version_manual(info_url: str, version: str) -> bool:
|
||
base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/")
|
||
zip_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip"
|
||
try:
|
||
zip_bytes = _read_binary_url(zip_url)
|
||
except Exception as exc:
|
||
_log(f"Download fehlgeschlagen ({zip_url}): {exc}", xbmc.LOGWARNING)
|
||
return False
|
||
|
||
if xbmcvfs is None:
|
||
return False
|
||
|
||
addons_root = xbmcvfs.translatePath("special://home/addons")
|
||
addons_root_real = os.path.realpath(addons_root)
|
||
try:
|
||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
|
||
for member in archive.infolist():
|
||
name = str(member.filename or "")
|
||
if not name or name.endswith("/"):
|
||
continue
|
||
target = os.path.realpath(os.path.join(addons_root, name))
|
||
if not target.startswith(addons_root_real + os.sep):
|
||
continue
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
with archive.open(member, "r") as src, open(target, "wb") as dst:
|
||
dst.write(src.read())
|
||
except Exception as exc:
|
||
_log(f"Entpacken fehlgeschlagen: {exc}", xbmc.LOGWARNING)
|
||
return False
|
||
|
||
builtin = getattr(xbmc, "executebuiltin", None)
|
||
if callable(builtin):
|
||
builtin("UpdateLocalAddons")
|
||
return True
|
||
|
||
|
||
def _resolve_zip_url(info_url: str, version: str) -> str:
|
||
"""Gibt die Download-URL fuer eine bestimmte Version zurueck.
|
||
|
||
Prueft zuerst das Kodi-Repo, dann den Gitea-Release-Download.
|
||
"""
|
||
base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/")
|
||
repo_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip"
|
||
# Pruefen ob die ZIP im Kodi-Repo existiert
|
||
try:
|
||
req = Request(repo_url, method="HEAD", headers={"User-Agent": "ViewIT/1.0"})
|
||
resp = urlopen(req, timeout=UPDATE_HTTP_TIMEOUT_SEC)
|
||
if resp.status == 200:
|
||
return repo_url
|
||
except Exception:
|
||
pass
|
||
# Fallback: Gitea Release Asset
|
||
identity = _extract_repo_identity(info_url)
|
||
if identity:
|
||
scheme, host, owner, repo_branch = identity
|
||
repo = repo_branch.split("|", 1)[0]
|
||
source_repo = repo.replace("ViewIT-Kodi-Repo", "ViewIT").replace("-Kodi-Repo", "")
|
||
return f"{scheme}://{host}/{owner}/{source_repo}/releases/download/v{version}/{UPDATE_ADDON_ID}-{version}.zip"
|
||
return repo_url
|
||
|
||
|
||
def _install_addon_version(info_url: str, version: str) -> bool:
|
||
zip_url = _resolve_zip_url(info_url, version)
|
||
|
||
# Prefer Kodi's own installer to get proper install flow and dependency handling.
|
||
builtin = getattr(xbmc, "executebuiltin", None)
|
||
if callable(builtin):
|
||
try:
|
||
before = _installed_addon_version_from_disk()
|
||
builtin(f"InstallAddon({zip_url})")
|
||
for _ in range(20):
|
||
time.sleep(1)
|
||
current = _installed_addon_version_from_disk()
|
||
if current == version:
|
||
return True
|
||
if before == version:
|
||
return True
|
||
except Exception as exc:
|
||
_log(f"InstallAddon fehlgeschlagen, fallback aktiv: {exc}", xbmc.LOGWARNING)
|
||
|
||
return _install_addon_version_manual(info_url, version)
|
||
|
||
|
||
def _sync_update_channel_status_settings() -> None:
|
||
channel = _selected_update_channel()
|
||
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 _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}", xbmc.LOGWARNING)
|
||
return False
|
||
|
||
|
||
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 callable(has_addon):
|
||
try:
|
||
return bool(has_addon(f"System.HasAddon({addon_id})"))
|
||
except Exception:
|
||
pass
|
||
if xbmcvfs is None:
|
||
return False
|
||
try:
|
||
addon_xml = xbmcvfs.translatePath(f"special://home/addons/{addon_id}/addon.xml")
|
||
except Exception:
|
||
return False
|
||
return bool(addon_xml and os.path.exists(addon_xml))
|
||
|
||
|
||
def _sync_resolveurl_status_setting() -> None:
|
||
status = "Installiert" if _is_addon_installed(RESOLVEURL_ADDON_ID) else "Fehlt"
|
||
_set_setting_string("resolveurl_status", status)
|
||
|
||
|
||
def _install_kodi_addon(addon_id: str, *, wait_seconds: int) -> bool:
|
||
if _is_addon_installed(addon_id):
|
||
return True
|
||
builtin = getattr(xbmc, "executebuiltin", None)
|
||
if not callable(builtin):
|
||
return False
|
||
try:
|
||
builtin(f"InstallAddon({addon_id})")
|
||
builtin("UpdateLocalAddons")
|
||
except Exception as exc:
|
||
_log(f"InstallAddon fehlgeschlagen ({addon_id}): {exc}", xbmc.LOGWARNING)
|
||
return False
|
||
|
||
if wait_seconds <= 0:
|
||
return _is_addon_installed(addon_id)
|
||
deadline = time.time() + max(1, int(wait_seconds))
|
||
while time.time() < deadline:
|
||
if _is_addon_installed(addon_id):
|
||
return True
|
||
time.sleep(1)
|
||
return _is_addon_installed(addon_id)
|
||
|
||
|
||
def _ensure_resolveurl_installed(*, force: bool, silent: bool) -> bool:
|
||
if _is_addon_installed(RESOLVEURL_ADDON_ID):
|
||
_sync_resolveurl_status_setting()
|
||
return True
|
||
if not force and not _get_setting_bool("resolveurl_auto_install", default=True):
|
||
_sync_resolveurl_status_setting()
|
||
return False
|
||
|
||
now = int(time.time())
|
||
if not force:
|
||
last_try = _get_setting_int("resolveurl_last_ts", default=0)
|
||
if last_try > 0 and (now - last_try) < RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC:
|
||
return False
|
||
_set_setting_string("resolveurl_last_ts", str(now))
|
||
|
||
wait_seconds = 20 if force else 0
|
||
ok = _install_kodi_addon(RESOLVEURL_ADDON_ID, wait_seconds=wait_seconds)
|
||
_sync_resolveurl_status_setting()
|
||
|
||
if not silent:
|
||
if ok:
|
||
xbmcgui.Dialog().notification(
|
||
"ResolveURL",
|
||
"script.module.resolveurl ist installiert.",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
4000,
|
||
)
|
||
else:
|
||
xbmcgui.Dialog().notification(
|
||
"ResolveURL",
|
||
"Installation fehlgeschlagen. Bitte Repository/Netzwerk pruefen.",
|
||
xbmcgui.NOTIFICATION_ERROR,
|
||
5000,
|
||
)
|
||
return ok
|
||
|
||
|
||
def _maybe_auto_install_resolveurl(action: str | None) -> None:
|
||
if (action or "").strip():
|
||
return
|
||
_ensure_resolveurl_installed(force=False, silent=True)
|
||
|
||
|
||
def _sync_tmdb_active_key_setting() -> None:
|
||
from core.metadata import _resolve_tmdb_api_key
|
||
raw_key = _get_setting_string("tmdb_api_key").strip()
|
||
active_key = _resolve_tmdb_api_key(raw_key)
|
||
if active_key:
|
||
masked = active_key[:6] + "…" + active_key[-4:] if len(active_key) > 10 else active_key
|
||
else:
|
||
masked = "(kein)"
|
||
_set_setting_string("tmdb_api_key_active", masked)
|
||
|
||
|
||
def _sync_ytdlp_status_setting() -> None:
|
||
status = "Installiert" if _is_addon_installed(YTDLP_ADDON_ID) else "Fehlt"
|
||
_set_setting_string("ytdlp_status", status)
|
||
|
||
|
||
def _fetch_ytdlp_zip_url() -> str:
|
||
"""Ermittelt die aktuellste ZIP-URL fuer script.module.yt-dlp von GitHub."""
|
||
api_url = "https://api.github.com/repos/lekma/script.module.yt-dlp/releases/latest"
|
||
try:
|
||
data = json.loads(_read_binary_url(api_url, timeout=10).decode("utf-8"))
|
||
for asset in data.get("assets", []):
|
||
url = asset.get("browser_download_url", "")
|
||
if url.endswith(".zip"):
|
||
return url
|
||
except Exception as exc:
|
||
_log(f"yt-dlp Release-URL nicht ermittelbar: {exc}", xbmc.LOGWARNING)
|
||
return ""
|
||
|
||
|
||
def _install_ytdlp_from_zip(zip_url: str) -> bool:
|
||
"""Laedt script.module.yt-dlp ZIP von GitHub und entpackt es in den Addons-Ordner."""
|
||
try:
|
||
zip_bytes = _read_binary_url(zip_url, timeout=60)
|
||
except Exception as exc:
|
||
_log(f"yt-dlp ZIP-Download fehlgeschlagen: {exc}", xbmc.LOGWARNING)
|
||
return False
|
||
if xbmcvfs is None:
|
||
return False
|
||
addons_root = xbmcvfs.translatePath("special://home/addons")
|
||
addons_root_real = os.path.realpath(addons_root)
|
||
try:
|
||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
|
||
for member in archive.infolist():
|
||
name = str(member.filename or "")
|
||
if not name or name.endswith("/"):
|
||
continue
|
||
target = os.path.realpath(os.path.join(addons_root, name))
|
||
if not target.startswith(addons_root_real + os.sep):
|
||
continue
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
with archive.open(member, "r") as src, open(target, "wb") as dst:
|
||
dst.write(src.read())
|
||
except Exception as exc:
|
||
_log(f"yt-dlp Entpacken fehlgeschlagen: {exc}", xbmc.LOGWARNING)
|
||
return False
|
||
builtin = getattr(xbmc, "executebuiltin", None)
|
||
if callable(builtin):
|
||
builtin("UpdateLocalAddons")
|
||
return _is_addon_installed(YTDLP_ADDON_ID)
|
||
|
||
|
||
def _ensure_ytdlp_installed(*, force: bool, silent: bool) -> bool:
|
||
if _is_addon_installed(YTDLP_ADDON_ID):
|
||
_sync_ytdlp_status_setting()
|
||
return True
|
||
|
||
zip_url = _fetch_ytdlp_zip_url()
|
||
if not zip_url:
|
||
_sync_ytdlp_status_setting()
|
||
if not silent:
|
||
xbmcgui.Dialog().notification(
|
||
"yt-dlp",
|
||
"Aktuelle Version nicht ermittelbar (GitHub nicht erreichbar?).",
|
||
xbmcgui.NOTIFICATION_ERROR,
|
||
5000,
|
||
)
|
||
return False
|
||
|
||
ok = _install_ytdlp_from_zip(zip_url)
|
||
_sync_ytdlp_status_setting()
|
||
|
||
if not silent:
|
||
if ok:
|
||
xbmcgui.Dialog().notification(
|
||
"yt-dlp",
|
||
"script.module.yt-dlp ist installiert.",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
4000,
|
||
)
|
||
else:
|
||
xbmcgui.Dialog().notification(
|
||
"yt-dlp",
|
||
"Installation fehlgeschlagen.",
|
||
xbmcgui.NOTIFICATION_ERROR,
|
||
5000,
|
||
)
|
||
return ok
|
||
|
||
|
||
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_ytdlp_status_setting()
|
||
_sync_update_channel_status_settings()
|
||
_sync_tmdb_active_key_setting()
|
||
|
||
|
||
def _show_root_menu() -> None:
|
||
handle = _get_handle()
|
||
_log("Root-Menue wird angezeigt.")
|
||
|
||
# Update-Hinweis ganz oben wenn neuere Version verfügbar
|
||
installed = _get_setting_string("update_installed_version").strip()
|
||
available = _get_setting_string("update_available_selected").strip()
|
||
if installed and available and available not in ("-", "", "0.0.0") and _version_sort_key(available) > _version_sort_key(installed):
|
||
_add_directory_item(
|
||
handle,
|
||
f"Update verfuegbar: {installed} -> {available}",
|
||
"select_update_version",
|
||
)
|
||
|
||
_add_directory_item(handle, "Suche in allen Quellen", "search")
|
||
|
||
plugins = _discover_plugins()
|
||
for plugin_name in sorted(plugins.keys(), key=lambda value: value.casefold()):
|
||
_add_directory_item(handle, plugin_name, "plugin_menu", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
# Trakt-Menue (nur wenn aktiviert)
|
||
if _get_setting_bool("trakt_enabled", default=False):
|
||
if _trakt_load_token():
|
||
_add_directory_item(handle, "Weiterschauen", "trakt_continue", is_folder=True)
|
||
_add_directory_item(handle, "Trakt Upcoming", "trakt_upcoming", is_folder=True)
|
||
_add_directory_item(handle, "Trakt Watchlist", "trakt_watchlist", is_folder=True)
|
||
_add_directory_item(handle, "Trakt History", "trakt_history", {"page": "1"}, is_folder=True)
|
||
else:
|
||
_add_directory_item(handle, "Trakt autorisieren", "trakt_auth", is_folder=True)
|
||
|
||
_add_directory_item(handle, "Einstellungen", "settings")
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_plugin_menu(plugin_name: str) -> None:
|
||
handle = _get_handle()
|
||
plugin_name = (plugin_name or "").strip()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if not plugin:
|
||
xbmcgui.Dialog().notification("Quelle", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, plugin_name)
|
||
|
||
if callable(getattr(plugin, "search_titles", None)):
|
||
_add_directory_item(handle, "Suche", "plugin_search", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "new_titles") or _plugin_has_capability(plugin, "latest_episodes"):
|
||
_add_directory_item(handle, LATEST_MENU_LABEL, "latest_titles", {"plugin": plugin_name, "page": "1"}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "genres"):
|
||
_add_directory_item(handle, "Genres", "genres", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "alpha"):
|
||
_add_directory_item(handle, "A-Z", "alpha_index", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "series_catalog"):
|
||
_add_directory_item(handle, "Serien", "series_catalog", {"plugin": plugin_name, "page": "1"}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "popular_series"):
|
||
_add_directory_item(handle, POPULAR_MENU_LABEL, "popular", {"plugin": plugin_name, "page": "1"}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "year_filter"):
|
||
_add_directory_item(handle, "Nach Jahr", "year_menu", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "country_filter"):
|
||
_add_directory_item(handle, "Nach Land", "country_menu", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "collections"):
|
||
_add_directory_item(handle, "Sammlungen", "collections_menu", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "tags"):
|
||
_add_directory_item(handle, "Schlagworte", "tags_menu", {"plugin": plugin_name}, is_folder=True)
|
||
|
||
if _plugin_has_capability(plugin, "random"):
|
||
_add_directory_item(handle, "Zufaelliger Titel", "random_title", {"plugin": plugin_name}, is_folder=False)
|
||
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_plugin_search(plugin_name: str) -> None:
|
||
plugin_name = (plugin_name or "").strip()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if not plugin:
|
||
xbmcgui.Dialog().notification("Suche", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
_show_root_menu()
|
||
return
|
||
|
||
_log(f"Plugin-Suche gestartet: {plugin_name}")
|
||
dialog = xbmcgui.Dialog()
|
||
query = dialog.input(f"{plugin_name}: Titel eingeben", type=xbmcgui.INPUT_ALPHANUM).strip()
|
||
if not query:
|
||
_log("Plugin-Suche abgebrochen (leere Eingabe).", xbmc.LOGDEBUG)
|
||
_show_plugin_menu(plugin_name)
|
||
return
|
||
_log(f"Plugin-Suchbegriff ({plugin_name}): {query}", xbmc.LOGDEBUG)
|
||
_show_plugin_search_results(plugin_name, query)
|
||
|
||
|
||
def _show_plugin_search_results(plugin_name: str, query: str) -> None:
|
||
handle = _get_handle()
|
||
plugin_name = (plugin_name or "").strip()
|
||
query = (query or "").strip()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if not plugin:
|
||
xbmcgui.Dialog().notification("Suche", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {query}")
|
||
_set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows")
|
||
_log(f"Suche nach Titeln (Plugin={plugin_name}): {query}")
|
||
|
||
list_items: list[dict[str, object]] = []
|
||
canceled = False
|
||
try:
|
||
with _progress_dialog("Suche laeuft", f"{plugin_name} (1/1) startet...") as progress:
|
||
canceled = progress(5, f"{plugin_name} (1/1) Suche...")
|
||
plugin_progress = lambda msg="", pct=None: progress( # noqa: E731 - kompakte Callback-Bruecke
|
||
max(5, min(95, int(pct))) if pct is not None else 20,
|
||
f"{plugin_name} (1/1) {str(msg or 'Suche...').strip()}",
|
||
)
|
||
search_coro = _call_plugin_search(plugin, query, progress_callback=plugin_progress)
|
||
try:
|
||
results = _run_async(search_coro)
|
||
except Exception:
|
||
if inspect.iscoroutine(search_coro):
|
||
try:
|
||
search_coro.close()
|
||
except Exception:
|
||
pass
|
||
raise
|
||
results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()])
|
||
from search_utils import matches_query as _mq
|
||
results = [r for r in results if _mq(query, title=r)]
|
||
results.sort(key=lambda value: value.casefold())
|
||
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_enabled()
|
||
)
|
||
plugin_meta = _collect_plugin_metadata(plugin, results) if use_source else {}
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_titles = list(results) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in results:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles and not canceled:
|
||
canceled = progress(35, f"{plugin_name} (1/1) Metadaten...")
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(list(tmdb_titles))
|
||
|
||
total_results = max(1, len(results))
|
||
for index, title in enumerate(results, start=1):
|
||
if canceled:
|
||
break
|
||
if index == 1 or index == total_results or (index % 10 == 0):
|
||
pct = 35 + int((index / float(total_results)) * 60)
|
||
canceled = progress(pct, f"{plugin_name} (1/1) aufbereiten {index}/{total_results}")
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, []))
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
playstate = _title_playstate(plugin_name, title)
|
||
merged_info = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
display_label = _label_with_duration(title, info_labels)
|
||
display_label = _label_with_playstate(display_label, playstate)
|
||
direct_play = bool(plugin_name.casefold() == "einschalten" and _get_setting_bool("einschalten_enable_playback", default=False))
|
||
extra_params = _series_url_params(plugin, title)
|
||
list_items.append(
|
||
{
|
||
"label": display_label,
|
||
"action": "play_movie" if direct_play else "seasons",
|
||
"params": {"plugin": plugin_name, "title": title, **extra_params},
|
||
"is_folder": (not direct_play),
|
||
"info_labels": merged_info,
|
||
"art": art,
|
||
"cast": cast,
|
||
}
|
||
)
|
||
except Exception as exc:
|
||
_log(f"Suche fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Suche", "Suche fehlgeschlagen.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
if canceled and not list_items:
|
||
xbmcgui.Dialog().notification("Suche", "Suche abgebrochen.", xbmcgui.NOTIFICATION_INFO, 2500)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
for item in list_items:
|
||
_add_directory_item(
|
||
handle,
|
||
str(item["label"]),
|
||
str(item["action"]),
|
||
dict(item["params"]),
|
||
is_folder=bool(item["is_folder"]),
|
||
info_labels=item["info_labels"],
|
||
art=item["art"],
|
||
cast=item["cast"],
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _import_plugin_module(path: Path) -> ModuleType:
|
||
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
|
||
with _PLUGIN_CACHE_LOCK:
|
||
if _PLUGIN_CACHE is not None:
|
||
return _PLUGIN_CACHE
|
||
# Plugins werden dynamisch aus `plugins/*.py` geladen, damit Integrationen getrennt
|
||
# entwickelt und bei Fehlern isoliert deaktiviert werden koennen.
|
||
plugins: dict[str, BasisPlugin] = {}
|
||
if not PLUGIN_DIR.exists():
|
||
with _PLUGIN_CACHE_LOCK:
|
||
_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:
|
||
xbmc.log(f"Plugin-Datei {file_path.name} konnte nicht geladen werden: {exc}", xbmc.LOGWARNING)
|
||
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:
|
||
xbmc.log(f"Plugin {cls.__name__} konnte nicht geladen werden: {exc}", xbmc.LOGWARNING)
|
||
continue
|
||
if getattr(instance, "is_available", True) is False:
|
||
reason = getattr(instance, "unavailable_reason", "Nicht verfuegbar.")
|
||
xbmc.log(f"Plugin {cls.__name__} deaktiviert: {reason}", xbmc.LOGWARNING)
|
||
continue
|
||
plugin_name = str(getattr(instance, "name", "") or "").strip()
|
||
if not plugin_name:
|
||
xbmc.log(
|
||
f"Plugin {cls.__name__} wurde ohne Name registriert und wird uebersprungen.",
|
||
xbmc.LOGWARNING,
|
||
)
|
||
continue
|
||
if plugin_name in plugins:
|
||
xbmc.log(
|
||
f"Plugin-Name doppelt ({plugin_name}), {cls.__name__} wird uebersprungen.",
|
||
xbmc.LOGWARNING,
|
||
)
|
||
continue
|
||
plugins[plugin_name] = instance
|
||
plugins = dict(sorted(plugins.items(), key=lambda item: item[0].casefold()))
|
||
with _PLUGIN_CACHE_LOCK:
|
||
_PLUGIN_CACHE = plugins
|
||
return plugins
|
||
|
||
|
||
def _run_async(coro):
|
||
"""Fuehrt eine Coroutine aus, auch wenn Kodi bereits einen Event-Loop hat."""
|
||
_ensure_windows_selector_policy()
|
||
|
||
def _run_with_asyncio_run():
|
||
return asyncio.run(coro)
|
||
|
||
try:
|
||
running_loop = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
running_loop = None
|
||
|
||
if running_loop and running_loop.is_running():
|
||
result_box: dict[str, object] = {}
|
||
error_box: dict[str, BaseException] = {}
|
||
|
||
def _worker() -> None:
|
||
try:
|
||
result_box["value"] = _run_with_asyncio_run()
|
||
except BaseException as exc: # pragma: no cover - defensive
|
||
error_box["error"] = exc
|
||
|
||
worker = threading.Thread(target=_worker, name="viewit-async-runner")
|
||
worker.start()
|
||
worker.join()
|
||
if "error" in error_box:
|
||
raise error_box["error"]
|
||
return result_box.get("value")
|
||
|
||
return _run_with_asyncio_run()
|
||
|
||
|
||
def _series_url_params(plugin: BasisPlugin, title: str) -> dict[str, str]:
|
||
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 {}
|
||
|
||
|
||
def _clean_search_titles(values: list[str]) -> list[str]:
|
||
"""Filtert offensichtliche Platzhalter und dedupliziert Treffer."""
|
||
blocked = {
|
||
"stream",
|
||
"streams",
|
||
"film",
|
||
"movie",
|
||
"play",
|
||
"details",
|
||
"details/play",
|
||
}
|
||
cleaned: list[str] = []
|
||
seen: set[str] = set()
|
||
for raw in values:
|
||
title = (raw or "").strip()
|
||
if not title:
|
||
continue
|
||
key = title.casefold()
|
||
if key in blocked:
|
||
continue
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
cleaned.append(title)
|
||
return cleaned
|
||
|
||
|
||
def _show_search() -> None:
|
||
_log("Suche gestartet.")
|
||
dialog = xbmcgui.Dialog()
|
||
query = dialog.input("Titel eingeben", type=xbmcgui.INPUT_ALPHANUM).strip()
|
||
if not query:
|
||
_log("Suche abgebrochen (leere Eingabe).", xbmc.LOGDEBUG)
|
||
_show_root_menu()
|
||
return
|
||
_log(f"Suchbegriff: {query}", xbmc.LOGDEBUG)
|
||
_show_search_results(query)
|
||
|
||
|
||
def _show_search_results(query: str) -> None:
|
||
handle = _get_handle()
|
||
_log(f"Suche nach Titeln: {query}")
|
||
_set_content(handle, "tvshows")
|
||
plugins = _discover_plugins()
|
||
if not plugins:
|
||
xbmcgui.Dialog().notification("Suche", "Keine Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
# grouped: casefold-key → Liste der Plugin-Einträge für diesen Titel
|
||
grouped: dict[str, list[dict[str, object]]] = {}
|
||
canceled = False
|
||
plugin_entries = list(plugins.items())
|
||
total_plugins = max(1, len(plugin_entries))
|
||
with _progress_dialog("Suche laeuft", "Suche startet...") as progress:
|
||
for plugin_index, (plugin_name, plugin) in enumerate(plugin_entries, start=1):
|
||
range_start = int(((plugin_index - 1) / float(total_plugins)) * 100)
|
||
range_end = int((plugin_index / float(total_plugins)) * 100)
|
||
canceled = progress(range_start, f"{plugin_name} ({plugin_index}/{total_plugins}) Suche...")
|
||
if canceled:
|
||
break
|
||
plugin_progress = lambda msg="", pct=None: progress( # noqa: E731 - kompakte Callback-Bruecke
|
||
max(range_start, min(range_end, int(pct))) if pct is not None else range_start + 20,
|
||
f"{plugin_name} ({plugin_index}/{total_plugins}) {str(msg or 'Suche...').strip()}",
|
||
)
|
||
search_coro = _call_plugin_search(plugin, query, progress_callback=plugin_progress)
|
||
try:
|
||
results = _run_async(search_coro)
|
||
except Exception as exc:
|
||
if inspect.iscoroutine(search_coro):
|
||
try:
|
||
search_coro.close()
|
||
except Exception:
|
||
pass
|
||
_log(f"Suche fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
continue
|
||
results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()])
|
||
from search_utils import matches_query as _mq
|
||
results = [r for r in results if _mq(query, title=r)]
|
||
_log(f"Treffer ({plugin_name}): {len(results)}", xbmc.LOGDEBUG)
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_enabled()
|
||
)
|
||
plugin_meta = _collect_plugin_metadata(plugin, results) if use_source else {}
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_titles = list(results) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in results:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles:
|
||
canceled = progress(
|
||
range_start + int((range_end - range_start) * 0.35),
|
||
f"{plugin_name} ({plugin_index}/{total_plugins}) Metadaten...",
|
||
)
|
||
if canceled:
|
||
break
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(list(tmdb_titles))
|
||
total_results = max(1, len(results))
|
||
for title_index, title in enumerate(results, start=1):
|
||
if title_index == 1 or title_index == total_results or (title_index % 10 == 0):
|
||
canceled = progress(
|
||
range_start + int((range_end - range_start) * (0.35 + 0.65 * (title_index / float(total_results)))),
|
||
f"{plugin_name} ({plugin_index}/{total_plugins}) aufbereiten {title_index}/{total_results}",
|
||
)
|
||
if canceled:
|
||
break
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, []))
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
playstate = _title_playstate(plugin_name, title)
|
||
merged_info = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
label = _label_with_duration(title, info_labels)
|
||
label = _label_with_playstate(label, playstate)
|
||
direct_play = bool(
|
||
plugin_name.casefold() == "einschalten" and _get_setting_bool("einschalten_enable_playback", default=False)
|
||
)
|
||
extra_params = _series_url_params(plugin, title)
|
||
key = title.casefold()
|
||
grouped.setdefault(key, []).append({
|
||
"title": title,
|
||
"plugin_name": plugin_name,
|
||
"extra_params": extra_params,
|
||
"label_base": label,
|
||
"direct_play": direct_play,
|
||
"info_labels": merged_info,
|
||
"art": art,
|
||
"cast": cast,
|
||
})
|
||
if canceled:
|
||
break
|
||
if not canceled:
|
||
progress(100, "Suche fertig")
|
||
if canceled and not grouped:
|
||
xbmcgui.Dialog().notification("Suche", "Suche abgebrochen.", xbmcgui.NOTIFICATION_INFO, 2500)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
# Gruppierte Einträge alphabetisch ausgeben
|
||
list_items: list[dict[str, object]] = []
|
||
for key in sorted(grouped):
|
||
entries = grouped[key]
|
||
first = entries[0]
|
||
canonical_title = str(first["title"])
|
||
if len(entries) == 1:
|
||
# Nur ein Plugin → direkt zur Staffel-Ansicht
|
||
direct_play = bool(first["direct_play"])
|
||
list_items.append({
|
||
"label": first["label_base"],
|
||
"action": "play_movie" if direct_play else "seasons",
|
||
"params": {"plugin": first["plugin_name"], "title": canonical_title, **dict(first["extra_params"])},
|
||
"is_folder": not direct_play,
|
||
"info_labels": first["info_labels"],
|
||
"art": first["art"],
|
||
"cast": first["cast"],
|
||
})
|
||
else:
|
||
# Mehrere Plugins → Zwischenstufe "Quelle wählen"
|
||
plugin_list = ",".join(str(e["plugin_name"]) for e in entries)
|
||
list_items.append({
|
||
"label": first["label_base"],
|
||
"action": "choose_source",
|
||
"params": {"title": canonical_title, "plugins": plugin_list},
|
||
"is_folder": True,
|
||
"info_labels": first["info_labels"],
|
||
"art": first["art"],
|
||
"cast": first["cast"],
|
||
})
|
||
|
||
for item in list_items:
|
||
_add_directory_item(
|
||
handle,
|
||
str(item["label"]),
|
||
str(item["action"]),
|
||
dict(item["params"]),
|
||
is_folder=bool(item["is_folder"]),
|
||
info_labels=item["info_labels"],
|
||
art=item["art"],
|
||
cast=item["cast"],
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_choose_source(title: str, plugins_str: str) -> None:
|
||
"""Zeigt Quellenauswahl wenn ein Titel in mehreren Plugins verfügbar ist."""
|
||
handle = _get_handle()
|
||
title = (title or "").strip()
|
||
plugin_names = [p.strip() for p in (plugins_str or "").split(",") if p.strip()]
|
||
all_plugins = _discover_plugins()
|
||
|
||
xbmcplugin.setPluginCategory(handle, title)
|
||
_set_content(handle, "tvshows")
|
||
|
||
for plugin_name in plugin_names:
|
||
plugin = all_plugins.get(plugin_name)
|
||
if not plugin:
|
||
continue
|
||
extra_params = _series_url_params(plugin, title)
|
||
_add_directory_item(
|
||
handle,
|
||
plugin_name,
|
||
"seasons",
|
||
{"plugin": plugin_name, "title": title, **extra_params},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _movie_seed_for_title(plugin: BasisPlugin, title: str, seasons: list[str]) -> tuple[str, str] | None:
|
||
"""Ermittelt ein Film-Seed (Season/Episode), um direkt Provider anzeigen zu können."""
|
||
if not seasons or len(seasons) != 1:
|
||
return None
|
||
season = str(seasons[0] or "").strip()
|
||
if not season:
|
||
return None
|
||
try:
|
||
episodes = [str(value or "").strip() for value in (plugin.episodes_for(title, season) or [])]
|
||
except Exception:
|
||
return None
|
||
episodes = [value for value in episodes if value]
|
||
if len(episodes) != 1:
|
||
return None
|
||
episode = episodes[0]
|
||
season_key = season.casefold()
|
||
episode_key = episode.casefold()
|
||
title_key = (title or "").strip().casefold()
|
||
generic_seasons = {"film", "movie", "stream"}
|
||
generic_episodes = {"stream", "film", "play", title_key}
|
||
if season_key in generic_seasons and episode_key in generic_episodes:
|
||
return (season, episode)
|
||
return None
|
||
|
||
|
||
def _show_movie_streams(
|
||
plugin_name: str,
|
||
title: str,
|
||
season: str,
|
||
episode: str,
|
||
*,
|
||
series_url: str = "",
|
||
) -> None:
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Streams", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
if series_url:
|
||
remember_series_url = getattr(plugin, "remember_series_url", None)
|
||
if callable(remember_series_url):
|
||
try:
|
||
remember_series_url(title, series_url)
|
||
except Exception:
|
||
pass
|
||
|
||
xbmcplugin.setPluginCategory(handle, f"{title} - Streams")
|
||
_set_content(handle, "videos")
|
||
|
||
base_params = {"plugin": plugin_name, "title": title, "season": season, "episode": episode}
|
||
if series_url:
|
||
base_params["series_url"] = series_url
|
||
|
||
# Hoster bleiben im Auswahldialog der Wiedergabe (wie bisher).
|
||
_add_directory_item(
|
||
handle,
|
||
title,
|
||
"play_episode",
|
||
dict(base_params),
|
||
is_folder=False,
|
||
info_labels={"title": title, "mediatype": "movie"},
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None:
|
||
handle = _get_handle()
|
||
_log(f"Staffeln laden: {plugin_name} / {title}")
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Staffeln", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
if series_url:
|
||
remember_series_url = getattr(plugin, "remember_series_url", None)
|
||
if callable(remember_series_url):
|
||
try:
|
||
remember_series_url(title, series_url)
|
||
except Exception:
|
||
pass
|
||
|
||
use_source, show_tmdb, _prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_enabled()
|
||
)
|
||
title_info_labels: dict[str, str] | None = None
|
||
title_art: dict[str, str] | None = None
|
||
title_cast: list[TmdbCastMember] | None = None
|
||
meta_getter = getattr(plugin, "metadata_for", None)
|
||
if use_source and callable(meta_getter):
|
||
try:
|
||
with _busy_dialog("Metadaten werden geladen..."):
|
||
meta_labels, meta_art, meta_cast = meta_getter(title)
|
||
if isinstance(meta_labels, dict):
|
||
title_info_labels = {str(k): str(v) for k, v in meta_labels.items() if v}
|
||
if isinstance(meta_art, dict):
|
||
title_art = {str(k): str(v) for k, v in meta_art.items() if v}
|
||
if isinstance(meta_cast, list):
|
||
# type: ignore[assignment] - plugins may return cast in their own shape; best-effort only
|
||
title_cast = meta_cast # noqa: PGH003
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
seasons = plugin.seasons_for(title)
|
||
except Exception as exc:
|
||
_log(f"Staffeln laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Staffeln", "Staffeln konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
movie_seed = _movie_seed_for_title(plugin, title, seasons)
|
||
if movie_seed is not None:
|
||
# Dieser Action-Pfad wurde als Verzeichnis aufgerufen. Ohne endOfDirectory()
|
||
# bleibt Kodi im Busy-Zustand, auch wenn wir direkt in die Wiedergabe springen.
|
||
try:
|
||
xbmcplugin.endOfDirectory(handle, succeeded=False)
|
||
except Exception:
|
||
try:
|
||
xbmcplugin.endOfDirectory(handle)
|
||
except Exception:
|
||
pass
|
||
_play_episode(
|
||
plugin_name,
|
||
title,
|
||
movie_seed[0],
|
||
movie_seed[1],
|
||
series_url=series_url,
|
||
)
|
||
return
|
||
|
||
count = len(seasons)
|
||
suffix = "Staffel" if count == 1 else "Staffeln"
|
||
xbmcplugin.setPluginCategory(handle, f"{title} ({count} {suffix})")
|
||
_set_content(handle, "seasons")
|
||
# Staffel-Metadaten (Plot/Poster) optional via TMDB.
|
||
if show_tmdb:
|
||
_tmdb_labels_and_art(title)
|
||
api_key = _get_setting_string("tmdb_api_key").strip() if show_tmdb else ""
|
||
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)}"
|
||
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
|
||
for season in seasons:
|
||
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:
|
||
cache_key = (_tmdb_cache_get(_TMDB_ID_CACHE, (title or "").strip().casefold(), 0), season_number, language, flags)
|
||
cached = _tmdb_cache_get(_TMDB_SEASON_SUMMARY_CACHE, cache_key)
|
||
if cached is None and cache_key[0]:
|
||
try:
|
||
meta = lookup_tv_season_summary(
|
||
tmdb_id=cache_key[0],
|
||
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={cache_key[0]} 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 {}))
|
||
season_state = _season_playstate(plugin_name, title, season)
|
||
merged_labels = _apply_playstate_to_info(dict(merged_labels), season_state)
|
||
merged_art: dict[str, str] | None = art
|
||
if title_art:
|
||
merged_art = dict(title_art)
|
||
if isinstance(art, dict):
|
||
merged_art.update({k: str(v) for k, v in art.items() if v})
|
||
|
||
_add_directory_item(
|
||
handle,
|
||
_label_with_playstate(season, season_state),
|
||
"episodes",
|
||
{"plugin": plugin_name, "title": title, "season": season, "series_url": series_url},
|
||
is_folder=True,
|
||
info_labels=merged_labels or None,
|
||
art=merged_art,
|
||
cast=title_cast,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_episodes(plugin_name: str, title: str, season: str, series_url: str = "") -> None:
|
||
handle = _get_handle()
|
||
_log(f"Episoden laden: {plugin_name} / {title} / {season}")
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Episoden", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
if series_url:
|
||
remember_series_url = getattr(plugin, "remember_series_url", None)
|
||
if callable(remember_series_url):
|
||
try:
|
||
remember_series_url(title, series_url)
|
||
except Exception:
|
||
pass
|
||
season_number = _extract_first_int(season)
|
||
if season_number is not None:
|
||
xbmcplugin.setPluginCategory(handle, f"{title} - Staffel {season_number}")
|
||
else:
|
||
xbmcplugin.setPluginCategory(handle, f"{title} - {season}")
|
||
_set_content(handle, "episodes")
|
||
|
||
episodes = list(plugin.episodes_for(title, season))
|
||
if episodes:
|
||
episode_url_getter = getattr(plugin, "episode_url_for", None)
|
||
supports_direct_episode_url = callable(getattr(plugin, "stream_link_for_url", None))
|
||
use_source, show_tmdb, _prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_enabled()
|
||
)
|
||
show_info: dict[str, str] = {}
|
||
show_art: dict[str, str] = {}
|
||
show_cast: list[TmdbCastMember] | None = None
|
||
if show_tmdb:
|
||
show_info, show_art, show_cast = _tmdb_labels_and_art(title)
|
||
elif use_source:
|
||
meta_getter = getattr(plugin, "metadata_for", None)
|
||
if callable(meta_getter):
|
||
try:
|
||
with _busy_dialog("Episoden-Metadaten werden geladen..."):
|
||
meta_labels, meta_art, meta_cast = meta_getter(title)
|
||
if isinstance(meta_labels, dict):
|
||
show_info = {str(k): str(v) for k, v in meta_labels.items() if v}
|
||
if isinstance(meta_art, dict):
|
||
show_art = {str(k): str(v) for k, v in meta_art.items() if v}
|
||
if isinstance(meta_cast, list):
|
||
show_cast = meta_cast # noqa: PGH003
|
||
except Exception:
|
||
pass
|
||
|
||
show_fanart = (show_art or {}).get("fanart") if isinstance(show_art, dict) else ""
|
||
show_poster = (show_art or {}).get("poster") if isinstance(show_art, dict) else ""
|
||
trakt_watched = _trakt_watched_set(title)
|
||
with _busy_dialog("Episoden werden aufbereitet..."):
|
||
for episode in episodes:
|
||
if show_tmdb:
|
||
info_labels, art = _tmdb_episode_labels_and_art(
|
||
title=title, season_label=season, episode_label=episode
|
||
)
|
||
episode_cast = _tmdb_episode_cast(title=title, season_label=season, episode_label=episode)
|
||
else:
|
||
info_labels, art, episode_cast = {}, {}, []
|
||
merged_info = dict(show_info or {})
|
||
merged_info.update(dict(info_labels or {}))
|
||
merged_art: dict[str, str] = {}
|
||
if isinstance(show_art, dict):
|
||
merged_art.update({k: str(v) for k, v in show_art.items() if v})
|
||
if isinstance(art, dict):
|
||
merged_art.update({k: str(v) for k, v in art.items() if v})
|
||
|
||
# Kodi Info-Dialog für Episoden hängt oft an diesen Feldern.
|
||
season_number = _extract_first_int(season) or 0
|
||
episode_number = _extract_first_int(episode) or 0
|
||
merged_info.setdefault("mediatype", "episode")
|
||
merged_info.setdefault("tvshowtitle", title)
|
||
if season_number:
|
||
merged_info.setdefault("season", str(season_number))
|
||
if episode_number:
|
||
merged_info.setdefault("episode", str(episode_number))
|
||
|
||
# Trakt Watched-Status: gesehene Episoden mit playcount markieren.
|
||
if trakt_watched and season_number and episode_number:
|
||
if (season_number, episode_number) in trakt_watched:
|
||
merged_info["playcount"] = 1
|
||
merged_info["overlay"] = 7 # xbmcgui.ICON_OVERLAY_WATCHED
|
||
|
||
# Episode-Items ohne eigenes Artwork: Fanart/Poster vom Titel durchreichen.
|
||
if show_fanart:
|
||
merged_art.setdefault("fanart", show_fanart)
|
||
merged_art.setdefault("landscape", show_fanart)
|
||
if show_poster:
|
||
merged_art.setdefault("poster", show_poster)
|
||
|
||
key = _playstate_key(plugin_name=plugin_name, title=title, season=season, episode=episode)
|
||
merged_info = _apply_playstate_to_info(merged_info, _get_playstate(key))
|
||
|
||
display_label = episode
|
||
play_params = {
|
||
"plugin": plugin_name,
|
||
"title": title,
|
||
"season": season,
|
||
"episode": episode,
|
||
"series_url": series_url,
|
||
}
|
||
if supports_direct_episode_url and callable(episode_url_getter):
|
||
try:
|
||
episode_url = str(episode_url_getter(title, season, episode) or "").strip()
|
||
except Exception:
|
||
episode_url = ""
|
||
if episode_url:
|
||
play_params["url"] = episode_url
|
||
_add_directory_item(
|
||
handle,
|
||
display_label,
|
||
"play_episode",
|
||
play_params,
|
||
is_folder=False,
|
||
info_labels=merged_info,
|
||
art=merged_art,
|
||
cast=episode_cast or show_cast,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_genres(plugin_name: str) -> None:
|
||
handle = _get_handle()
|
||
_log(f"Genres laden: {plugin_name}")
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Genres", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
try:
|
||
genres = _run_with_progress(
|
||
"Genres",
|
||
f"{plugin_name}: Genres werden geladen...",
|
||
lambda: plugin.genres(),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"Genres konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Genres", "Genres konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
for genre in genres:
|
||
# Wenn Plugin Paging unterstützt, direkt paginierte Titelliste öffnen.
|
||
paging_getter = getattr(plugin, "titles_for_genre_page", None)
|
||
if callable(paging_getter):
|
||
_add_directory_item(
|
||
handle,
|
||
genre,
|
||
"genre_titles_page",
|
||
{"plugin": plugin_name, "genre": genre, "page": "1"},
|
||
is_folder=True,
|
||
)
|
||
continue
|
||
_add_directory_item(
|
||
handle,
|
||
genre,
|
||
"genre_series",
|
||
{"plugin": plugin_name, "genre": genre},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_paged_title_list(
|
||
plugin_name: str,
|
||
filter_value: str,
|
||
page: int,
|
||
dialog_label: str,
|
||
page_action: str,
|
||
filter_param: str,
|
||
paging_method: str,
|
||
count_method: str,
|
||
has_more_method: str | None,
|
||
) -> None:
|
||
"""Gemeinsame Implementierung fuer seitenweise Titellisten (Genre/Kategorie/A-Z)."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification(dialog_label, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
page = max(1, int(page or 1))
|
||
paging_getter = getattr(plugin, paging_method, None)
|
||
if not callable(paging_getter):
|
||
xbmcgui.Dialog().notification(dialog_label, "Seitenwechsel nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
total_pages = None
|
||
count_getter = getattr(plugin, count_method, None)
|
||
if callable(count_getter):
|
||
try:
|
||
total_pages = int(count_getter(filter_value) or 1)
|
||
except Exception:
|
||
total_pages = None
|
||
if total_pages is not None:
|
||
page = min(page, max(1, total_pages))
|
||
xbmcplugin.setPluginCategory(handle, f"{filter_value} ({page}/{total_pages})")
|
||
else:
|
||
xbmcplugin.setPluginCategory(handle, f"{filter_value} ({page})")
|
||
_set_content(handle, "movies" if (plugin_name or "").casefold() == "einschalten" else "tvshows")
|
||
|
||
if page > 1:
|
||
_add_directory_item(
|
||
handle,
|
||
"Vorherige Seite",
|
||
page_action,
|
||
{"plugin": plugin_name, filter_param: filter_value, "page": str(page - 1)},
|
||
is_folder=True,
|
||
)
|
||
|
||
try:
|
||
titles = _run_with_progress(
|
||
dialog_label,
|
||
f"{plugin_name}: {filter_value} Seite {page} wird geladen...",
|
||
lambda: list(paging_getter(filter_value, page) or []),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"{dialog_label}-Seite konnte nicht geladen werden ({plugin_name}/{filter_value} p{page}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(dialog_label, "Seite konnte nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
titles.sort(key=lambda value: value.casefold())
|
||
|
||
if titles:
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_list_enabled()
|
||
)
|
||
plugin_meta = _collect_plugin_metadata(plugin, titles) if use_source else {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
tmdb_titles = list(titles) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in titles:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles:
|
||
with _busy_dialog(f"{dialog_label}-Liste wird geladen..."):
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
|
||
for title in titles:
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels = dict(info_labels or {})
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
playstate = _title_playstate(plugin_name, title)
|
||
info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
display_label = _label_with_duration(title, info_labels)
|
||
display_label = _label_with_playstate(display_label, playstate)
|
||
direct_play = bool(
|
||
plugin_name.casefold() == "einschalten"
|
||
and _get_setting_bool("einschalten_enable_playback", default=False)
|
||
)
|
||
_add_directory_item(
|
||
handle,
|
||
display_label,
|
||
"play_movie" if direct_play else "seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=not direct_play,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
)
|
||
|
||
show_next = False
|
||
if total_pages is not None:
|
||
show_next = page < total_pages
|
||
elif has_more_method is not None:
|
||
has_more_getter = getattr(plugin, has_more_method, None)
|
||
if callable(has_more_getter):
|
||
try:
|
||
show_next = bool(has_more_getter(filter_value, page))
|
||
except Exception:
|
||
show_next = False
|
||
|
||
if show_next:
|
||
_add_directory_item(
|
||
handle,
|
||
"Naechste Seite",
|
||
page_action,
|
||
{"plugin": plugin_name, filter_param: filter_value, "page": str(page + 1)},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_genre_titles_page(plugin_name: str, genre: str, page: int = 1) -> None:
|
||
_show_paged_title_list(
|
||
plugin_name, genre, page,
|
||
dialog_label="Genres",
|
||
page_action="genre_titles_page",
|
||
filter_param="genre",
|
||
paging_method="titles_for_genre_page",
|
||
count_method="genre_page_count",
|
||
has_more_method="genre_has_more",
|
||
)
|
||
|
||
|
||
def _show_alpha_index(plugin_name: str) -> None:
|
||
handle = _get_handle()
|
||
_log(f"A-Z laden: {plugin_name}")
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("A-Z", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "alpha_index", None)
|
||
if not callable(getter):
|
||
xbmcgui.Dialog().notification("A-Z", "A-Z nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
try:
|
||
letters = _run_with_progress(
|
||
"A-Z",
|
||
f"{plugin_name}: Index wird geladen...",
|
||
lambda: list(getter() or []),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"A-Z konnte nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("A-Z", "A-Z konnte nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
for letter in letters:
|
||
letter = str(letter).strip()
|
||
if not letter:
|
||
continue
|
||
_add_directory_item(
|
||
handle,
|
||
letter,
|
||
"alpha_titles_page",
|
||
{"plugin": plugin_name, "letter": letter, "page": "1"},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_alpha_titles_page(plugin_name: str, letter: str, page: int = 1) -> None:
|
||
_show_paged_title_list(
|
||
plugin_name, letter, page,
|
||
dialog_label="A-Z",
|
||
page_action="alpha_titles_page",
|
||
filter_param="letter",
|
||
paging_method="titles_for_alpha_page",
|
||
count_method="alpha_page_count",
|
||
has_more_method=None,
|
||
)
|
||
|
||
|
||
def _show_series_catalog(plugin_name: str, page: int = 1) -> None:
|
||
handle = _get_handle()
|
||
plugin_name = (plugin_name or "").strip()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Serien", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
page = max(1, int(page or 1))
|
||
paging_getter = getattr(plugin, "series_catalog_page", None)
|
||
if not callable(paging_getter):
|
||
xbmcgui.Dialog().notification("Serien", "Serienkatalog nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
total_pages = None
|
||
count_getter = getattr(plugin, "series_catalog_page_count", None)
|
||
if callable(count_getter):
|
||
try:
|
||
total_pages = int(count_getter(page) or 1)
|
||
except Exception:
|
||
total_pages = None
|
||
if total_pages is not None:
|
||
page = min(page, max(1, total_pages))
|
||
xbmcplugin.setPluginCategory(handle, f"Serien ({page}/{total_pages})")
|
||
else:
|
||
xbmcplugin.setPluginCategory(handle, f"Serien ({page})")
|
||
_set_content(handle, "tvshows")
|
||
|
||
if page > 1:
|
||
_add_directory_item(
|
||
handle,
|
||
"Vorherige Seite",
|
||
"series_catalog",
|
||
{"plugin": plugin_name, "page": str(page - 1)},
|
||
is_folder=True,
|
||
)
|
||
|
||
try:
|
||
titles = _run_with_progress(
|
||
"Serien",
|
||
f"{plugin_name}: Seite {page} wird geladen...",
|
||
lambda: list(paging_getter(page) or []),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"Serien konnten nicht geladen werden ({plugin_name} p{page}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Serien", "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
titles.sort(key=lambda value: value.casefold())
|
||
|
||
if titles:
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_list_enabled()
|
||
)
|
||
plugin_meta = _collect_plugin_metadata(plugin, titles) if use_source else {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
tmdb_titles = list(titles) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in titles:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles:
|
||
with _busy_dialog("A-Z Seite wird geladen..."):
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
|
||
for title in titles:
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels = dict(info_labels or {})
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
playstate = _title_playstate(plugin_name, title)
|
||
info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
display_label = _label_with_duration(title, info_labels)
|
||
display_label = _label_with_playstate(display_label, playstate)
|
||
_add_directory_item(
|
||
handle,
|
||
display_label,
|
||
"seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=True,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
)
|
||
|
||
show_next = False
|
||
if total_pages is not None:
|
||
show_next = page < total_pages
|
||
else:
|
||
has_more_getter = getattr(plugin, "series_catalog_has_more", None)
|
||
if callable(has_more_getter):
|
||
try:
|
||
show_next = bool(has_more_getter(page))
|
||
except Exception:
|
||
show_next = False
|
||
|
||
if show_next:
|
||
_add_directory_item(
|
||
handle,
|
||
"Naechste Seite",
|
||
"series_catalog",
|
||
{"plugin": plugin_name, "page": str(page + 1)},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _title_group_key(title: str) -> str:
|
||
raw = (title or "").strip()
|
||
if not raw:
|
||
return "#"
|
||
for char in raw:
|
||
if char.isdigit():
|
||
return "0-9"
|
||
if char.isalpha():
|
||
normalized = char.casefold()
|
||
if normalized == "ä":
|
||
normalized = "a"
|
||
elif normalized == "ö":
|
||
normalized = "o"
|
||
elif normalized == "ü":
|
||
normalized = "u"
|
||
elif normalized == "ß":
|
||
normalized = "s"
|
||
return normalized.upper()
|
||
return "#"
|
||
|
||
|
||
def _genre_title_groups() -> list[tuple[str, str]]:
|
||
return [
|
||
("A-E", "A-E"),
|
||
("F-J", "F-J"),
|
||
("K-O", "K-O"),
|
||
("P-T", "P-T"),
|
||
("U-Z", "U-Z"),
|
||
("0-9", "0-9"),
|
||
]
|
||
|
||
|
||
def _group_matches(group_code: str, title: str) -> bool:
|
||
key = _title_group_key(title)
|
||
if group_code == "0-9":
|
||
return key == "0-9"
|
||
if key == "0-9" or key == "#":
|
||
return False
|
||
if group_code == "A-E":
|
||
return "A" <= key <= "E"
|
||
if group_code == "F-J":
|
||
return "F" <= key <= "J"
|
||
if group_code == "K-O":
|
||
return "K" <= key <= "O"
|
||
if group_code == "P-T":
|
||
return "P" <= key <= "T"
|
||
if group_code == "U-Z":
|
||
return "U" <= key <= "Z"
|
||
return False
|
||
|
||
|
||
def _get_genre_titles(plugin_name: str, genre: str) -> list[str]:
|
||
cache_key = (plugin_name, genre)
|
||
with _GENRE_TITLES_CACHE_LOCK:
|
||
cached = _GENRE_TITLES_CACHE.get(cache_key)
|
||
if cached is not None:
|
||
return list(cached)
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
return []
|
||
titles = plugin.titles_for_genre(genre)
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
titles.sort(key=lambda value: value.casefold())
|
||
with _GENRE_TITLES_CACHE_LOCK:
|
||
_GENRE_TITLES_CACHE[cache_key] = list(titles)
|
||
if len(_GENRE_TITLES_CACHE) > _CACHE_MAXSIZE:
|
||
excess = len(_GENRE_TITLES_CACHE) - _CACHE_MAXSIZE // 2
|
||
for k in list(_GENRE_TITLES_CACHE.keys())[:excess]:
|
||
del _GENRE_TITLES_CACHE[k]
|
||
return list(titles)
|
||
|
||
|
||
def _show_genre_series(plugin_name: str, genre: str) -> None:
|
||
handle = _get_handle()
|
||
xbmcplugin.setPluginCategory(handle, genre)
|
||
for label, group_code in _genre_title_groups():
|
||
_add_directory_item(
|
||
handle,
|
||
label,
|
||
"genre_series_group",
|
||
{"plugin": plugin_name, "genre": genre, "group": group_code},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _parse_positive_int(value: str, *, default: int = 1) -> int:
|
||
try:
|
||
parsed = int(str(value or "").strip())
|
||
except Exception:
|
||
return default
|
||
return parsed if parsed > 0 else default
|
||
|
||
|
||
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 _plugin_has_capability(plugin: BasisPlugin, capability: str) -> bool:
|
||
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 _plugins_with_popular() -> list[tuple[str, BasisPlugin, str]]:
|
||
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 _show_popular(plugin_name: str | None = None, page: int = 1) -> None:
|
||
handle = _get_handle()
|
||
page_size = LIST_PAGE_SIZE
|
||
page = max(1, int(page or 1))
|
||
|
||
if plugin_name:
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None or not _plugin_has_capability(plugin, "popular_series"):
|
||
xbmcgui.Dialog().notification(POPULAR_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
try:
|
||
popular_getter = getattr(plugin, "popular_series", None)
|
||
if callable(popular_getter):
|
||
titles = _run_with_progress(
|
||
POPULAR_MENU_LABEL,
|
||
f"{plugin_name}: Liste wird geladen...",
|
||
lambda: list(popular_getter() or []),
|
||
)
|
||
else:
|
||
label = _popular_genre_label(plugin)
|
||
if not label:
|
||
titles = []
|
||
else:
|
||
titles = _run_with_progress(
|
||
POPULAR_MENU_LABEL,
|
||
f"{plugin_name}: Liste wird geladen...",
|
||
lambda: list(plugin.titles_for_genre(label) or []),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"{POPULAR_MENU_LABEL} konnte nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(POPULAR_MENU_LABEL, "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
titles.sort(key=lambda value: value.casefold())
|
||
total = len(titles)
|
||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||
page = min(page, total_pages)
|
||
xbmcplugin.setPluginCategory(handle, f"{POPULAR_MENU_LABEL} [{plugin_name}] ({page}/{total_pages})")
|
||
_set_content(handle, "tvshows")
|
||
|
||
if total_pages > 1 and page > 1:
|
||
_add_directory_item(
|
||
handle,
|
||
"Vorherige Seite",
|
||
"popular",
|
||
{"plugin": plugin_name, "page": str(page - 1)},
|
||
is_folder=True,
|
||
)
|
||
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
page_items = titles[start:end]
|
||
|
||
if page_items:
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_list_enabled()
|
||
)
|
||
plugin_meta = _collect_plugin_metadata(plugin, page_items) if use_source else {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
tmdb_titles = list(page_items) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in page_items:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles:
|
||
with _busy_dialog(f"{POPULAR_MENU_LABEL} wird geladen..."):
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
|
||
for title in page_items:
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
playstate = _title_playstate(plugin_name, title)
|
||
info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
display_label = _label_with_duration(title, info_labels)
|
||
display_label = _label_with_playstate(display_label, playstate)
|
||
_add_directory_item(
|
||
handle,
|
||
display_label,
|
||
"seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=True,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
)
|
||
|
||
if total_pages > 1 and page < total_pages:
|
||
_add_directory_item(
|
||
handle,
|
||
"Naechste Seite",
|
||
"popular",
|
||
{"plugin": plugin_name, "page": str(page + 1)},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
sources = _plugins_with_popular()
|
||
if not sources:
|
||
xbmcgui.Dialog().notification(POPULAR_MENU_LABEL, "Keine Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, POPULAR_MENU_LABEL)
|
||
for name, plugin, _label in sources:
|
||
_add_directory_item(
|
||
handle,
|
||
f"{POPULAR_MENU_LABEL} [{plugin.name}]",
|
||
"popular",
|
||
{"plugin": name, "page": "1"},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_new_titles(plugin_name: str, page: int = 1, *, action_name: str = "new_titles") -> None:
|
||
handle = _get_handle()
|
||
page = max(1, int(page or 1))
|
||
max_items_key = f"{(plugin_name or '').strip().casefold()}_max_page_items"
|
||
page_size = _get_setting_int(max_items_key, default=15) or LIST_PAGE_SIZE
|
||
|
||
plugin_name = (plugin_name or "").strip()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None or not _plugin_has_capability(plugin, "new_titles"):
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
getter = getattr(plugin, "new_titles", None)
|
||
if not callable(getter):
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Diese Liste ist nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
paging_getter = getattr(plugin, "new_titles_page", None)
|
||
has_more_getter = getattr(plugin, "new_titles_has_more", None)
|
||
|
||
if callable(paging_getter):
|
||
xbmcplugin.setPluginCategory(handle, f"{LATEST_MENU_LABEL} [{plugin_name}] ({page})")
|
||
_set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows")
|
||
if page > 1:
|
||
_add_directory_item(
|
||
handle,
|
||
"Vorherige Seite",
|
||
action_name,
|
||
{"plugin": plugin_name, "page": str(page - 1)},
|
||
is_folder=True,
|
||
)
|
||
try:
|
||
page_items = _run_with_progress(
|
||
LATEST_MENU_LABEL,
|
||
f"{plugin_name}: Seite {page} wird geladen...",
|
||
lambda: list(paging_getter(page) or []),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"{LATEST_MENU_LABEL} konnten nicht geladen werden ({plugin_name} p{page}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
page_items = [str(t).strip() for t in page_items if t and str(t).strip()]
|
||
if page_size > 0 and len(page_items) > page_size:
|
||
page_items = page_items[:page_size]
|
||
page_items.sort(key=lambda value: value.casefold())
|
||
else:
|
||
try:
|
||
titles = _run_with_progress(
|
||
LATEST_MENU_LABEL,
|
||
f"{plugin_name}: Liste wird geladen...",
|
||
lambda: list(getter() or []),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"{LATEST_MENU_LABEL} konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
titles.sort(key=lambda value: value.casefold())
|
||
total = len(titles)
|
||
if total == 0:
|
||
xbmcgui.Dialog().notification(
|
||
LATEST_MENU_LABEL,
|
||
"Keine Titel gefunden. Bitte Basis-URL oder Index pruefen.",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
4000,
|
||
)
|
||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||
page = min(page, total_pages)
|
||
xbmcplugin.setPluginCategory(handle, f"{LATEST_MENU_LABEL} [{plugin_name}] ({page}/{total_pages})")
|
||
_set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows")
|
||
|
||
if total_pages > 1 and page > 1:
|
||
_add_directory_item(
|
||
handle,
|
||
"Vorherige Seite",
|
||
action_name,
|
||
{"plugin": plugin_name, "page": str(page - 1)},
|
||
is_folder=True,
|
||
)
|
||
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
page_items = titles[start:end]
|
||
if page_items:
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_list_enabled()
|
||
)
|
||
plugin_meta = _collect_plugin_metadata(plugin, page_items) if use_source else {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
tmdb_titles = list(page_items) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in page_items:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles:
|
||
with _busy_dialog(f"{LATEST_MENU_LABEL} wird geladen..."):
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
|
||
for title in page_items:
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels = dict(info_labels or {})
|
||
is_direct_play = bool(
|
||
plugin_name.casefold() == "einschalten"
|
||
and _get_setting_bool("einschalten_enable_playback", default=False)
|
||
)
|
||
info_labels.setdefault("mediatype", "movie" if is_direct_play else "tvshow")
|
||
playstate = _title_playstate(plugin_name, title)
|
||
info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
display_label = _label_with_duration(title, info_labels)
|
||
display_label = _label_with_playstate(display_label, playstate)
|
||
direct_play = is_direct_play
|
||
_add_directory_item(
|
||
handle,
|
||
display_label,
|
||
"play_movie" if direct_play else "seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=not direct_play,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
)
|
||
|
||
show_next = False
|
||
if callable(paging_getter) and callable(has_more_getter):
|
||
try:
|
||
show_next = bool(has_more_getter(page))
|
||
except Exception:
|
||
show_next = False
|
||
elif callable(paging_getter) and page_items:
|
||
show_next = True
|
||
elif "total_pages" in locals():
|
||
show_next = bool(total_pages > 1 and page < total_pages) # type: ignore[name-defined]
|
||
|
||
if show_next:
|
||
_add_directory_item(
|
||
handle,
|
||
"Naechste Seite",
|
||
action_name,
|
||
{"plugin": plugin_name, "page": str(page + 1)},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_latest_episodes(plugin_name: str, page: int = 1) -> None:
|
||
handle = _get_handle()
|
||
plugin_name = (plugin_name or "").strip()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if not plugin:
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
getter = getattr(plugin, "latest_episodes", None)
|
||
if not callable(getter):
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Diese Quelle bietet das nicht an.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {LATEST_MENU_LABEL} (Seite {page})")
|
||
_set_content(handle, "episodes")
|
||
|
||
if page > 1:
|
||
_add_directory_item(handle, "Vorherige Seite", "latest_titles",
|
||
{"plugin": plugin_name, "page": str(page - 1)}, is_folder=True)
|
||
|
||
try:
|
||
entries = _run_with_progress(
|
||
LATEST_MENU_LABEL,
|
||
f"{plugin_name}: Seite {page} wird geladen...",
|
||
lambda: list(getter(page) or []),
|
||
)
|
||
except Exception as exc:
|
||
_log(f"{LATEST_MENU_LABEL} fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Abruf fehlgeschlagen.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
for entry in entries:
|
||
try:
|
||
title = str(getattr(entry, "series_title", "") or "").strip()
|
||
season_number = int(getattr(entry, "season", 0) or 0)
|
||
episode_number = int(getattr(entry, "episode", 0) or 0)
|
||
url = str(getattr(entry, "url", "") or "").strip()
|
||
airdate = str(getattr(entry, "airdate", "") or "").strip()
|
||
except Exception:
|
||
continue
|
||
if not title or not url or season_number < 0 or episode_number <= 0:
|
||
continue
|
||
|
||
season_label = f"Staffel {season_number}"
|
||
episode_label = f"Episode {episode_number}"
|
||
key = _playstate_key(plugin_name=plugin_name, title=title, season=season_label, episode=episode_label)
|
||
playstate = _get_playstate(key)
|
||
|
||
label = f"{title} - S{season_number:02d}E{episode_number:02d}"
|
||
label = _label_with_playstate(label, playstate)
|
||
|
||
info_labels: dict[str, object] = {
|
||
"title": f"{title} - S{season_number:02d}E{episode_number:02d}",
|
||
"tvshowtitle": title,
|
||
"season": season_number,
|
||
"episode": episode_number,
|
||
"mediatype": "episode",
|
||
}
|
||
info_labels = _apply_playstate_to_info(info_labels, playstate)
|
||
|
||
_add_directory_item(
|
||
handle,
|
||
label,
|
||
"play_episode_url",
|
||
{
|
||
"plugin": plugin_name,
|
||
"title": title,
|
||
"season": str(season_number),
|
||
"episode": str(episode_number),
|
||
"url": url,
|
||
},
|
||
is_folder=False,
|
||
info_labels=info_labels,
|
||
)
|
||
|
||
has_more_fn = getattr(plugin, "latest_episodes_has_more", None)
|
||
if callable(has_more_fn):
|
||
show_next = bool(has_more_fn(page))
|
||
else:
|
||
show_next = False
|
||
if show_next:
|
||
_add_directory_item(handle, "Naechste Seite", "latest_titles",
|
||
{"plugin": plugin_name, "page": str(page + 1)}, is_folder=True)
|
||
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_latest_titles(plugin_name: str, page: int = 1) -> None:
|
||
plugin_name = (plugin_name or "").strip()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
handle = _get_handle()
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
if _plugin_has_capability(plugin, "latest_episodes"):
|
||
_show_latest_episodes(plugin_name, page)
|
||
return
|
||
if _plugin_has_capability(plugin, "new_titles"):
|
||
_show_new_titles(plugin_name, page, action_name="latest_titles")
|
||
return
|
||
handle = _get_handle()
|
||
xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Diese Quelle bietet das nicht an.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page: int = 1) -> None:
|
||
handle = _get_handle()
|
||
page_size = LIST_PAGE_SIZE
|
||
page = max(1, int(page or 1))
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Genres", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
grouped_paging = getattr(plugin, "titles_for_genre_group_page", None)
|
||
grouped_has_more = getattr(plugin, "genre_group_has_more", None)
|
||
if callable(grouped_paging):
|
||
try:
|
||
raw_items = _run_with_progress(
|
||
"Genres",
|
||
f"{plugin_name}: {genre} [{group_code}] Seite {page} wird geladen...",
|
||
lambda: list(grouped_paging(genre, group_code, page, page_size) or []),
|
||
)
|
||
page_items = [str(t).strip() for t in raw_items if t and str(t).strip()]
|
||
except Exception as exc:
|
||
_log(f"Genre-Serien konnten nicht geladen werden ({plugin_name}/{genre}/{group_code} p{page}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Genres", "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, f"{genre} [{group_code}] ({page})")
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_list_enabled()
|
||
)
|
||
if page > 1:
|
||
_add_directory_item(
|
||
handle,
|
||
"Vorherige Seite",
|
||
"genre_series_group",
|
||
{"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page - 1)},
|
||
is_folder=True,
|
||
)
|
||
if page_items:
|
||
plugin_meta = _collect_plugin_metadata(plugin, page_items) if use_source else {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
tmdb_titles = list(page_items) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in page_items:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles:
|
||
with _busy_dialog("Genre-Gruppe wird geladen..."):
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
|
||
for title in page_items:
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels = dict(info_labels or {})
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
playstate = _title_playstate(plugin_name, title)
|
||
info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
display_label = _label_with_duration(title, info_labels)
|
||
display_label = _label_with_playstate(display_label, playstate)
|
||
_add_directory_item(
|
||
handle,
|
||
display_label,
|
||
"seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=True,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
)
|
||
show_next = False
|
||
if callable(grouped_has_more):
|
||
try:
|
||
show_next = bool(grouped_has_more(genre, group_code, page, page_size))
|
||
except Exception:
|
||
show_next = False
|
||
elif len(page_items) >= page_size:
|
||
show_next = True
|
||
if show_next:
|
||
_add_directory_item(
|
||
handle,
|
||
"Naechste Seite",
|
||
"genre_series_group",
|
||
{"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page + 1)},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
try:
|
||
titles = _get_genre_titles(plugin_name, genre)
|
||
except Exception as exc:
|
||
_log(f"Genre-Serien konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Genres", "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
filtered = [title for title in titles if _group_matches(group_code, title)]
|
||
total = len(filtered)
|
||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||
page = min(page, total_pages)
|
||
xbmcplugin.setPluginCategory(handle, f"{genre} [{group_code}] ({page}/{total_pages})")
|
||
|
||
if total_pages > 1 and page > 1:
|
||
_add_directory_item(
|
||
handle,
|
||
"Vorherige Seite",
|
||
"genre_series_group",
|
||
{"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page - 1)},
|
||
is_folder=True,
|
||
)
|
||
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
page_items = filtered[start:end]
|
||
use_source, show_tmdb, prefer_source = _metadata_policy(
|
||
plugin_name, plugin, allow_tmdb=_tmdb_list_enabled()
|
||
)
|
||
|
||
if page_items:
|
||
plugin_meta = _collect_plugin_metadata(plugin, page_items) if use_source else {}
|
||
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
|
||
show_art = _get_setting_bool("tmdb_show_art", default=True)
|
||
tmdb_prefetched: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember]]] = {}
|
||
tmdb_titles = list(page_items) if show_tmdb else []
|
||
if show_tmdb and prefer_source and use_source:
|
||
tmdb_titles = []
|
||
for title in page_items:
|
||
meta = plugin_meta.get(title)
|
||
meta_labels = meta[0] if meta else {}
|
||
meta_art = meta[1] if meta else {}
|
||
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
|
||
tmdb_titles.append(title)
|
||
if show_tmdb and tmdb_titles:
|
||
with _busy_dialog("Genre-Serien werden geladen..."):
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
|
||
for title in page_items:
|
||
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
|
||
meta = plugin_meta.get(title)
|
||
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
|
||
info_labels = dict(info_labels or {})
|
||
info_labels.setdefault("mediatype", "tvshow")
|
||
if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow":
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
playstate = _title_playstate(plugin_name, title)
|
||
info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
|
||
display_label = _label_with_duration(title, info_labels)
|
||
display_label = _label_with_playstate(display_label, playstate)
|
||
_add_directory_item(
|
||
handle,
|
||
display_label,
|
||
"seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=True,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
)
|
||
|
||
if total_pages > 1 and page < total_pages:
|
||
_add_directory_item(
|
||
handle,
|
||
"Naechste Seite",
|
||
"genre_series_group",
|
||
{"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page + 1)},
|
||
is_folder=True,
|
||
)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
def _open_settings() -> None:
|
||
"""Oeffnet das Kodi-Addon-Settings-Dialog."""
|
||
if xbmcaddon is None: # pragma: no cover - outside Kodi
|
||
raise RuntimeError("xbmcaddon ist nicht verfuegbar (KodiStub).")
|
||
_sync_update_version_settings()
|
||
addon = xbmcaddon.Addon()
|
||
addon.openSettings()
|
||
|
||
|
||
def _apply_update_channel(*, silent: bool = False) -> bool:
|
||
if xbmc is None: # pragma: no cover - outside Kodi
|
||
return False
|
||
info_url = _resolve_update_info_url()
|
||
channel = _selected_update_channel()
|
||
_sync_update_version_settings()
|
||
applied = _update_repository_source(info_url)
|
||
installed_version = _get_setting_string("update_installed_version").strip() or "0.0.0"
|
||
versions = _filter_versions_for_channel(channel, _fetch_repo_versions(info_url))
|
||
target_version = versions[0] if versions else "-"
|
||
|
||
install_result = False
|
||
if target_version != "-" and target_version != installed_version:
|
||
install_result = _install_addon_version(info_url, target_version)
|
||
elif target_version == installed_version:
|
||
install_result = True
|
||
|
||
builtin = getattr(xbmc, "executebuiltin", None)
|
||
if callable(builtin):
|
||
builtin("UpdateAddonRepos")
|
||
builtin("UpdateLocalAddons")
|
||
if not silent:
|
||
if not applied:
|
||
warning_icon = getattr(xbmcgui, "NOTIFICATION_WARNING", xbmcgui.NOTIFICATION_INFO)
|
||
xbmcgui.Dialog().notification(
|
||
"Updates",
|
||
"Kanal gespeichert, aber repository.viewit nicht gefunden.",
|
||
warning_icon,
|
||
5000,
|
||
)
|
||
elif target_version == "-":
|
||
xbmcgui.Dialog().notification(
|
||
"Updates",
|
||
"Kanal angewendet, aber keine Version im Kanal gefunden.",
|
||
xbmcgui.NOTIFICATION_ERROR,
|
||
5000,
|
||
)
|
||
elif not install_result:
|
||
xbmcgui.Dialog().notification(
|
||
"Updates",
|
||
f"Kanal angewendet, Installation von {target_version} fehlgeschlagen.",
|
||
xbmcgui.NOTIFICATION_ERROR,
|
||
5000,
|
||
)
|
||
elif target_version == installed_version:
|
||
xbmcgui.Dialog().notification(
|
||
"Updates",
|
||
f"Kanal angewendet: {_channel_label(_selected_update_channel())} ({target_version} bereits installiert)",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
4500,
|
||
)
|
||
else:
|
||
xbmcgui.Dialog().notification(
|
||
"Updates",
|
||
f"Kanal angewendet: {_channel_label(_selected_update_channel())} -> {target_version} installiert",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
5000,
|
||
)
|
||
_sync_update_version_settings()
|
||
return applied and install_result
|
||
|
||
|
||
def _run_update_check(*, silent: bool = False) -> None:
|
||
"""Stoesst Kodi-Repo- und Addon-Updates an."""
|
||
if xbmc is None: # pragma: no cover - outside Kodi
|
||
return
|
||
try:
|
||
_apply_update_channel(silent=True)
|
||
if not silent:
|
||
builtin = getattr(xbmc, "executebuiltin", None)
|
||
if callable(builtin):
|
||
builtin("ActivateWindow(addonbrowser,addons://updates/)")
|
||
if not silent:
|
||
xbmcgui.Dialog().notification("Updates", "Update-Check gestartet.", xbmcgui.NOTIFICATION_INFO, 4000)
|
||
except Exception as exc:
|
||
_log(f"Update-Pruefung fehlgeschlagen: {exc}", xbmc.LOGWARNING)
|
||
if not silent:
|
||
try:
|
||
xbmcgui.Dialog().notification("Updates", "Update-Check fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4000)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _show_version_selector() -> None:
|
||
if xbmc is None: # pragma: no cover - outside Kodi
|
||
return
|
||
|
||
info_url = _resolve_update_info_url()
|
||
channel = _selected_update_channel()
|
||
_sync_update_version_settings()
|
||
|
||
versions = _filter_versions_for_channel(channel, _fetch_repo_versions(info_url))
|
||
if not versions:
|
||
xbmcgui.Dialog().notification("Updates", "Keine Versionen im Repo gefunden.", xbmcgui.NOTIFICATION_ERROR, 4000)
|
||
return
|
||
|
||
installed = _get_setting_string("update_installed_version").strip() or "-"
|
||
options = []
|
||
for version in versions:
|
||
label = version
|
||
if version == installed:
|
||
label = f"{version} (installiert)"
|
||
options.append(label)
|
||
|
||
selected = xbmcgui.Dialog().select("Version waehlen", options)
|
||
if selected < 0 or selected >= len(versions):
|
||
return
|
||
|
||
version = versions[selected]
|
||
changelog = _fetch_changelog_for_channel(channel, version)
|
||
if changelog:
|
||
viewer = getattr(xbmcgui.Dialog(), "textviewer", None)
|
||
if callable(viewer):
|
||
try:
|
||
viewer(f"Changelog {version}", changelog)
|
||
except Exception:
|
||
pass
|
||
|
||
action = xbmcgui.Dialog().select(
|
||
f"Version {version} installieren?",
|
||
["Update installieren", "Abbrechen"],
|
||
)
|
||
if action != 0:
|
||
return
|
||
|
||
xbmcgui.Dialog().notification("Updates", f"Installation gestartet: {version}", xbmcgui.NOTIFICATION_INFO, 2500)
|
||
ok = _install_addon_version(info_url, version)
|
||
if ok:
|
||
_sync_update_version_settings()
|
||
xbmcgui.Dialog().notification("Updates", f"Version {version} installiert.", xbmcgui.NOTIFICATION_INFO, 4000)
|
||
else:
|
||
xbmcgui.Dialog().notification("Updates", f"Installation von {version} fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4500)
|
||
|
||
|
||
def _maybe_run_auto_update_check(action: str | None) -> None:
|
||
action = (action or "").strip()
|
||
# Auto-Check nur beim Root-Menue, nicht in jedem Untermenue.
|
||
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)
|
||
interval_idx = _get_setting_int("auto_update_interval", default=1)
|
||
interval_sec = _AUTO_UPDATE_INTERVALS[min(interval_idx, len(_AUTO_UPDATE_INTERVALS) - 1)]
|
||
if last > 0 and (now - last) < interval_sec:
|
||
return
|
||
_set_setting_string("auto_update_last_ts", str(now))
|
||
_run_update_check(silent=True)
|
||
|
||
|
||
def _extract_first_int(value: str) -> int | None:
|
||
match = re.search(r"(\d+)", value or "")
|
||
if not match:
|
||
return None
|
||
try:
|
||
return int(match.group(1))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _label_with_duration(label: str, info_labels: dict[str, str] | None) -> str:
|
||
return label
|
||
|
||
|
||
def _resolveurl_last_error() -> str:
|
||
try:
|
||
from resolveurl_backend import get_last_error # type: ignore
|
||
except Exception:
|
||
return ""
|
||
try:
|
||
return str(get_last_error() or "")
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _is_cloudflare_challenge_error(message: str) -> bool:
|
||
text = str(message or "").casefold()
|
||
if not text:
|
||
return False
|
||
return "cloudflare" in text or "challenge" in text or "attention required" in text
|
||
|
||
|
||
def _is_resolveurl_missing_error(message: str) -> bool:
|
||
return str(message or "").strip().casefold() == "resolveurl missing"
|
||
|
||
|
||
def _resolve_stream_with_retry(plugin: BasisPlugin, link: str) -> str | None:
|
||
"""Löst einen Stream-Link auf mit ResolveURL-Auto-Install und CF-Check.
|
||
|
||
Gibt den finalen Link zurück oder None (mit Kodi-Notification bei Fehler).
|
||
"""
|
||
resolved_link = plugin.resolve_stream_link(link)
|
||
if not resolved_link:
|
||
err = _resolveurl_last_error()
|
||
if _is_resolveurl_missing_error(err):
|
||
_log("ResolveURL fehlt: versuche Auto-Installation.", xbmc.LOGWARNING)
|
||
_ensure_resolveurl_installed(force=True, silent=True)
|
||
resolved_link = plugin.resolve_stream_link(link)
|
||
err = _resolveurl_last_error()
|
||
if _is_cloudflare_challenge_error(err):
|
||
_log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(
|
||
"Wiedergabe",
|
||
"Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
4500,
|
||
)
|
||
return None
|
||
if not resolved_link:
|
||
_log("Stream konnte nicht aufgeloest werden.", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(
|
||
"Wiedergabe",
|
||
"Stream konnte nicht aufgeloest werden.",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
3000,
|
||
)
|
||
return None
|
||
final_link = normalize_resolved_stream_url(resolved_link, source_url=link)
|
||
err = _resolveurl_last_error()
|
||
if _is_cloudflare_challenge_error(err) and final_link.strip() == link.strip():
|
||
_log(f"ResolveURL Cloudflare-Challenge (unresolved): {err}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification(
|
||
"Wiedergabe",
|
||
"Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.",
|
||
xbmcgui.NOTIFICATION_INFO,
|
||
4500,
|
||
)
|
||
return None
|
||
return final_link
|
||
|
||
|
||
def _is_inputstream_adaptive_available() -> bool:
|
||
"""Prueft ob inputstream.adaptive in Kodi installiert ist."""
|
||
try:
|
||
import xbmcaddon # type: ignore
|
||
xbmcaddon.Addon("inputstream.adaptive")
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lokaler MPD-Manifest-Server fuer inputstream.adaptive
|
||
# ---------------------------------------------------------------------------
|
||
_mpd_server_instance = None
|
||
_mpd_server_port = 0
|
||
|
||
|
||
def _ensure_mpd_server() -> int:
|
||
"""Startet einen lokalen HTTP-Server der MPD-Manifeste serviert.
|
||
|
||
Gibt den Port zurueck. Der Server laeuft in einem Daemon-Thread.
|
||
"""
|
||
global _mpd_server_instance, _mpd_server_port
|
||
if _mpd_server_instance is not None:
|
||
return _mpd_server_port
|
||
|
||
import http.server
|
||
import socketserver
|
||
import threading
|
||
|
||
_pending_manifests: dict[str, str] = {}
|
||
|
||
class _ManifestHandler(http.server.BaseHTTPRequestHandler):
|
||
def do_GET(self) -> None:
|
||
if "/manifest" in self.path:
|
||
key = self.path.split("key=")[-1].split("&")[0] if "key=" in self.path else ""
|
||
content = _pending_manifests.pop(key, "")
|
||
if content:
|
||
data = content.encode("utf-8")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "application/dash+xml")
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
return
|
||
self.send_error(404)
|
||
|
||
def log_message(self, *_args: object) -> None:
|
||
pass # kein Logging
|
||
|
||
server = socketserver.TCPServer(("127.0.0.1", 0), _ManifestHandler)
|
||
_mpd_server_port = server.server_address[1]
|
||
_mpd_server_instance = server
|
||
|
||
# pending_manifests als Attribut am Server speichern
|
||
server._pending_manifests = _pending_manifests # type: ignore[attr-defined]
|
||
|
||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||
t.start()
|
||
_log(f"MPD-Server gestartet auf Port {_mpd_server_port}", xbmc.LOGDEBUG)
|
||
return _mpd_server_port
|
||
|
||
|
||
def _register_mpd_manifest(mpd_xml: str) -> str:
|
||
"""Registriert ein MPD-Manifest und gibt die lokale URL zurueck."""
|
||
import hashlib
|
||
port = _ensure_mpd_server()
|
||
key = hashlib.md5(mpd_xml.encode()).hexdigest()[:12]
|
||
if _mpd_server_instance is not None:
|
||
_mpd_server_instance._pending_manifests[key] = mpd_xml # type: ignore[attr-defined]
|
||
return f"http://127.0.0.1:{port}/plugin.video.viewit/manifest?key={key}"
|
||
|
||
|
||
def _play_dual_stream(
|
||
video_url: str,
|
||
audio_url: str,
|
||
*,
|
||
meta: dict[str, str] | None = None,
|
||
display_title: str | None = None,
|
||
info_labels: dict[str, str] | None = None,
|
||
art: dict[str, str] | None = None,
|
||
cast: list[TmdbCastMember] | None = None,
|
||
resolve_handle: int | None = None,
|
||
trakt_media: dict[str, object] | None = None,
|
||
) -> None:
|
||
"""Spielt getrennte Video+Audio-Streams via inputstream.adaptive.
|
||
|
||
Startet einen lokalen HTTP-Server der ein generiertes MPD-Manifest
|
||
serviert (gem. inputstream.adaptive Wiki: Integration + Custom Manifest).
|
||
Fallback auf Video-only wenn inputstream.adaptive nicht installiert.
|
||
"""
|
||
if not _is_inputstream_adaptive_available():
|
||
_log("inputstream.adaptive nicht verfuegbar – Video-only Wiedergabe", xbmc.LOGWARNING)
|
||
_play_final_link(
|
||
video_url, display_title=display_title, info_labels=info_labels,
|
||
art=art, cast=cast, resolve_handle=resolve_handle, trakt_media=trakt_media,
|
||
)
|
||
return
|
||
|
||
from xml.sax.saxutils import escape as xml_escape
|
||
|
||
m = meta or {}
|
||
vcodec = m.get("vc", "avc1.640028")
|
||
acodec = m.get("ac", "mp4a.40.2")
|
||
w = m.get("w", "1920")
|
||
h = m.get("h", "1080")
|
||
fps = m.get("fps", "25")
|
||
vbr = m.get("vbr", "5000000")
|
||
abr = m.get("abr", "128000")
|
||
asr = m.get("asr", "44100")
|
||
ach = m.get("ach", "2")
|
||
dur = m.get("dur", "0")
|
||
|
||
dur_attr = ""
|
||
if dur and dur != "0":
|
||
dur_attr = f' mediaPresentationDuration="PT{dur}S"'
|
||
|
||
mpd_xml = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static"'
|
||
' minBufferTime="PT2S"'
|
||
' profiles="urn:mpeg:dash:profile:isoff-on-demand:2011"'
|
||
+ dur_attr + '>'
|
||
'<Period>'
|
||
'<AdaptationSet mimeType="video/mp4" contentType="video" subsegmentAlignment="true">'
|
||
'<Representation id="video" bandwidth="' + vbr + '"'
|
||
' codecs="' + xml_escape(vcodec) + '"'
|
||
' width="' + w + '" height="' + h + '"'
|
||
' frameRate="' + fps + '">'
|
||
'<BaseURL>' + xml_escape(video_url) + '</BaseURL>'
|
||
'</Representation>'
|
||
'</AdaptationSet>'
|
||
'<AdaptationSet mimeType="audio/mp4" contentType="audio" subsegmentAlignment="true">'
|
||
'<Representation id="audio" bandwidth="' + abr + '"'
|
||
' codecs="' + xml_escape(acodec) + '"'
|
||
' audioSamplingRate="' + asr + '">'
|
||
'<AudioChannelConfiguration'
|
||
' schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"'
|
||
' value="' + ach + '"/>'
|
||
'<BaseURL>' + xml_escape(audio_url) + '</BaseURL>'
|
||
'</Representation>'
|
||
'</AdaptationSet>'
|
||
'</Period>'
|
||
'</MPD>'
|
||
)
|
||
|
||
mpd_url = _register_mpd_manifest(mpd_xml)
|
||
_log(f"MPD-Manifest URL: {mpd_url}", xbmc.LOGDEBUG)
|
||
|
||
list_item = xbmcgui.ListItem(label=display_title or "", path=mpd_url)
|
||
list_item.setMimeType("application/dash+xml")
|
||
list_item.setContentLookup(False)
|
||
list_item.setProperty("inputstream", "inputstream.adaptive")
|
||
list_item.setProperty("inputstream.adaptive.manifest_type", "mpd")
|
||
|
||
merged_info: dict[str, object] = dict(info_labels or {})
|
||
if display_title:
|
||
merged_info["title"] = display_title
|
||
_apply_video_info(list_item, merged_info, cast)
|
||
if art:
|
||
setter = getattr(list_item, "setArt", None)
|
||
if callable(setter):
|
||
try:
|
||
setter(art)
|
||
except Exception:
|
||
pass
|
||
|
||
resolved = False
|
||
if resolve_handle is not None:
|
||
resolver = getattr(xbmcplugin, "setResolvedUrl", None)
|
||
if callable(resolver):
|
||
try:
|
||
resolver(resolve_handle, True, list_item)
|
||
resolved = True
|
||
except Exception:
|
||
pass
|
||
if not resolved:
|
||
xbmc.Player().play(item=mpd_url, listitem=list_item)
|
||
|
||
if trakt_media and _get_setting_bool("trakt_enabled", default=False):
|
||
_trakt_scrobble_start_async(trakt_media)
|
||
_trakt_monitor_playback(trakt_media)
|
||
|
||
|
||
def _play_final_link(
|
||
link: str,
|
||
*,
|
||
display_title: str | None = None,
|
||
info_labels: dict[str, str] | None = None,
|
||
art: dict[str, str] | None = None,
|
||
cast: list[TmdbCastMember] | None = None,
|
||
resolve_handle: int | None = None,
|
||
trakt_media: dict[str, object] | None = None,
|
||
) -> None:
|
||
# Getrennte Video+Audio-Streams (yt-dlp): via inputstream.adaptive abspielen
|
||
audio_url = None
|
||
meta: dict[str, str] = {}
|
||
try:
|
||
from ytdlp_helper import split_video_audio
|
||
link, audio_url, meta = split_video_audio(link)
|
||
except Exception:
|
||
pass
|
||
|
||
if audio_url:
|
||
_play_dual_stream(
|
||
link, audio_url,
|
||
meta=meta,
|
||
display_title=display_title, info_labels=info_labels,
|
||
art=art, cast=cast, resolve_handle=resolve_handle,
|
||
trakt_media=trakt_media,
|
||
)
|
||
return
|
||
|
||
list_item = xbmcgui.ListItem(label=display_title or "", path=link)
|
||
try:
|
||
list_item.setProperty("IsPlayable", "true")
|
||
except Exception:
|
||
pass
|
||
merged_info: dict[str, object] = dict(info_labels or {})
|
||
if display_title:
|
||
merged_info["title"] = display_title
|
||
_apply_video_info(list_item, merged_info, cast)
|
||
if art:
|
||
setter = getattr(list_item, "setArt", None)
|
||
if callable(setter):
|
||
try:
|
||
setter(art)
|
||
except Exception:
|
||
pass
|
||
|
||
# Bei Plugin-Play-Items sollte Kodi via setResolvedUrl() die Wiedergabe starten.
|
||
# player.play() kann dazu führen, dass Kodi den Item-Callback nochmal triggert (Hoster-Auswahl doppelt).
|
||
resolved = False
|
||
if resolve_handle is not None:
|
||
resolver = getattr(xbmcplugin, "setResolvedUrl", None)
|
||
if callable(resolver):
|
||
try:
|
||
resolver(resolve_handle, True, list_item)
|
||
resolved = True
|
||
except Exception:
|
||
pass
|
||
|
||
if not resolved:
|
||
player = xbmc.Player()
|
||
player.play(item=link, listitem=list_item)
|
||
|
||
# Trakt Scrobble: Start senden, dann blockierend auf Wiedergabe-Ende warten
|
||
if trakt_media and _get_setting_bool("trakt_enabled", default=False):
|
||
_trakt_scrobble_start_async(trakt_media)
|
||
_trakt_monitor_playback(trakt_media)
|
||
|
||
|
||
def _trakt_scrobble_start_async(media: dict[str, object]) -> None:
|
||
"""Sendet scrobble/start an die Trakt-API in einem Hintergrund-Thread."""
|
||
def _do() -> None:
|
||
try:
|
||
from core.trakt import TraktClient
|
||
except Exception:
|
||
return
|
||
access_token = _get_setting_string("trakt_access_token").strip()
|
||
if not access_token:
|
||
return
|
||
client = TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG))
|
||
media_type = str(media.get("kind", "movie"))
|
||
tmdb_id = int(media.get("tmdb_id", 0))
|
||
imdb_id = str(media.get("imdb_id", ""))
|
||
client.scrobble_start(
|
||
access_token,
|
||
media_type=media_type,
|
||
title=str(media.get("title", "")),
|
||
tmdb_id=tmdb_id,
|
||
imdb_id=imdb_id,
|
||
season=int(media.get("season", 0)),
|
||
episode=int(media.get("episode", 0)),
|
||
)
|
||
if _get_setting_bool("trakt_auto_watchlist", default=False) and (tmdb_id or imdb_id):
|
||
try:
|
||
client.add_to_watchlist(access_token, media_type=media_type, tmdb_id=tmdb_id, imdb_id=imdb_id)
|
||
except Exception:
|
||
pass
|
||
threading.Thread(target=_do, daemon=True).start()
|
||
|
||
|
||
def _trakt_scrobble_stop_async(media: dict[str, object], progress: float = 100.0) -> None:
|
||
"""Sendet scrobble/stop an die Trakt-API in einem Hintergrund-Thread."""
|
||
def _do() -> None:
|
||
try:
|
||
from core.trakt import TraktClient
|
||
except Exception:
|
||
return
|
||
access_token = _get_setting_string("trakt_access_token").strip()
|
||
if not access_token:
|
||
return
|
||
client = TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG))
|
||
client.scrobble_stop(
|
||
access_token,
|
||
media_type=str(media.get("kind", "movie")),
|
||
title=str(media.get("title", "")),
|
||
tmdb_id=int(media.get("tmdb_id", 0)),
|
||
imdb_id=str(media.get("imdb_id", "")),
|
||
season=int(media.get("season", 0)),
|
||
episode=int(media.get("episode", 0)),
|
||
progress=progress,
|
||
)
|
||
_log(f"Trakt scrobble/stop: {media.get('title')} progress={progress:.0f}%", xbmc.LOGDEBUG)
|
||
threading.Thread(target=_do, daemon=True).start()
|
||
|
||
|
||
def _trakt_monitor_playback(media: dict[str, object]) -> None:
|
||
"""Blockiert bis die Wiedergabe endet, berechnet Fortschritt und sendet scrobble/stop.
|
||
|
||
Muss im Haupt-Thread nach player.play() / setResolvedUrl() aufgerufen werden,
|
||
damit der Plugin-Prozess bis zum Wiedergabe-Ende aktiv bleibt.
|
||
"""
|
||
monitor = xbmc.Monitor()
|
||
player = xbmc.Player()
|
||
|
||
# Warten bis Wiedergabe startet (max 15 Sekunden)
|
||
timeout = 0
|
||
while not player.isPlaying() and timeout < 15:
|
||
if monitor.waitForAbort(1):
|
||
return
|
||
timeout += 1
|
||
if not player.isPlaying():
|
||
_log("Trakt monitor: Wiedergabe nicht gestartet.", xbmc.LOGDEBUG)
|
||
return
|
||
|
||
last_pos: float = 0.0
|
||
total_time: float = 0.0
|
||
try:
|
||
total_time = player.getTotalTime()
|
||
except Exception:
|
||
pass
|
||
|
||
# Wiedergabe verfolgen (alle 5 Sekunden)
|
||
while player.isPlaying() and not monitor.abortRequested():
|
||
try:
|
||
last_pos = player.getTime()
|
||
if not total_time:
|
||
total_time = player.getTotalTime()
|
||
except Exception:
|
||
pass
|
||
monitor.waitForAbort(5)
|
||
|
||
if monitor.abortRequested():
|
||
return
|
||
|
||
progress = min(100.0, (last_pos / total_time * 100.0)) if total_time > 0 else 100.0
|
||
_log(f"Trakt monitor: Wiedergabe beendet, progress={progress:.0f}%", xbmc.LOGDEBUG)
|
||
_trakt_scrobble_stop_async(media, progress=progress)
|
||
|
||
|
||
def _track_playback_and_update_state_async(key: str) -> None:
|
||
# Eigenes Resume/Watched ist deaktiviert; Kodi verwaltet das selbst.
|
||
return
|
||
|
||
|
||
def _play_episode(
|
||
plugin_name: str,
|
||
title: str,
|
||
season: str,
|
||
episode: str,
|
||
*,
|
||
forced_hoster: str = "",
|
||
episode_url: str = "",
|
||
series_url: str = "",
|
||
resolve_handle: int | None = None,
|
||
) -> None:
|
||
episode_url = (episode_url or "").strip()
|
||
if episode_url:
|
||
_play_episode_url(
|
||
plugin_name,
|
||
title=title,
|
||
season_number=_extract_first_int(season) or 0,
|
||
episode_number=_extract_first_int(episode) or 0,
|
||
episode_url=episode_url,
|
||
season_label_override=season,
|
||
episode_label_override=episode,
|
||
resolve_handle=resolve_handle,
|
||
)
|
||
return
|
||
|
||
series_url = (series_url or "").strip()
|
||
if series_url:
|
||
plugin_for_url = _discover_plugins().get(plugin_name)
|
||
remember_series_url = getattr(plugin_for_url, "remember_series_url", None) if plugin_for_url is not None else None
|
||
if callable(remember_series_url):
|
||
try:
|
||
remember_series_url(title, series_url)
|
||
except Exception:
|
||
pass
|
||
|
||
_log(f"Play anfordern: {plugin_name} / {title} / {season} / {episode}")
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Wiedergabe", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
return
|
||
|
||
available_hosters: list[str] = []
|
||
hoster_getter = getattr(plugin, "available_hosters_for", None)
|
||
if callable(hoster_getter):
|
||
try:
|
||
with _busy_dialog("Hoster werden geladen..."):
|
||
available_hosters = list(hoster_getter(title, season, episode) or [])
|
||
except Exception as exc:
|
||
_log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
|
||
selected_hoster: str | None = None
|
||
forced_hoster = (forced_hoster or "").strip()
|
||
autoplay = _get_setting_bool("autoplay_enabled", default=False)
|
||
preferred = _get_setting_string("preferred_hoster").strip()
|
||
if available_hosters:
|
||
if forced_hoster:
|
||
for hoster in available_hosters:
|
||
if hoster.casefold() == forced_hoster.casefold():
|
||
selected_hoster = hoster
|
||
break
|
||
if selected_hoster is None and autoplay and preferred:
|
||
pref_lower = preferred.casefold()
|
||
for hoster in available_hosters:
|
||
if pref_lower in hoster.casefold():
|
||
selected_hoster = hoster
|
||
break
|
||
if selected_hoster is None:
|
||
selected_hoster = available_hosters[0]
|
||
_log(f"Autoplay: bevorzugter Hoster '{preferred}' nicht gefunden, nutze '{selected_hoster}'.", xbmc.LOGDEBUG)
|
||
if selected_hoster is None and len(available_hosters) == 1:
|
||
selected_hoster = available_hosters[0]
|
||
elif selected_hoster is None:
|
||
selected_index = xbmcgui.Dialog().select("Hoster waehlen", available_hosters)
|
||
if selected_index is None or selected_index < 0:
|
||
_log("Play abgebrochen (kein Hoster gewählt).", xbmc.LOGDEBUG)
|
||
return
|
||
selected_hoster = available_hosters[selected_index]
|
||
|
||
# Manche Plugins erlauben (optional) eine temporaere Einschränkung auf einen Hoster.
|
||
preferred_setter = getattr(plugin, "set_preferred_hosters", None)
|
||
restore_hosters: list[str] | None = None
|
||
if selected_hoster and callable(preferred_setter):
|
||
current = getattr(plugin, "_preferred_hosters", None)
|
||
if isinstance(current, list):
|
||
restore_hosters = list(current)
|
||
preferred_setter([selected_hoster])
|
||
|
||
try:
|
||
link = plugin.stream_link_for(title, season, episode)
|
||
if not link:
|
||
_log("Kein Stream gefunden.", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
return
|
||
_log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
|
||
final_link = _resolve_stream_with_retry(plugin, link)
|
||
if not final_link:
|
||
return
|
||
finally:
|
||
if restore_hosters is not None and callable(preferred_setter):
|
||
preferred_setter(restore_hosters)
|
||
|
||
_log(f"Finaler Link: {final_link}", xbmc.LOGDEBUG)
|
||
season_number = _extract_first_int(season)
|
||
episode_number = _extract_first_int(episode)
|
||
if season_number is not None and episode_number is not None:
|
||
display_title = f"{title} - S{season_number:02d}E{episode_number:02d}"
|
||
else:
|
||
display_title = title
|
||
info_labels, art, cast = _tmdb_labels_and_art(title)
|
||
display_title = _label_with_duration(display_title, info_labels)
|
||
|
||
# Trakt-IDs fuer script.trakt-Kompatibilitaet und eigenes Scrobbling
|
||
title_key = (title or "").strip().casefold()
|
||
_tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key, 0)
|
||
_imdb_id = ""
|
||
_kind = _tmdb_cache_get(_MEDIA_TYPE_CACHE, title_key, "tv") if _tmdb_id else "tv"
|
||
if _tmdb_id:
|
||
_imdb_id = _fetch_and_cache_imdb_id(title_key, _tmdb_id, _kind)
|
||
_set_trakt_ids_property(title, _tmdb_id, _imdb_id)
|
||
trakt_media: dict[str, object] = {
|
||
"title": title, "tmdb_id": _tmdb_id, "imdb_id": _imdb_id, "kind": _kind,
|
||
"season": season_number or 0, "episode": episode_number or 0,
|
||
}
|
||
|
||
_play_final_link(
|
||
final_link,
|
||
display_title=display_title,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
resolve_handle=resolve_handle,
|
||
trakt_media=trakt_media,
|
||
)
|
||
_track_playback_and_update_state_async(
|
||
_playstate_key(plugin_name=plugin_name, title=title, season=season, episode=episode)
|
||
)
|
||
|
||
|
||
def _play_episode_url(
|
||
plugin_name: str,
|
||
*,
|
||
title: str,
|
||
season_number: int,
|
||
episode_number: int,
|
||
episode_url: str,
|
||
season_label_override: str = "",
|
||
episode_label_override: str = "",
|
||
resolve_handle: int | None = None,
|
||
) -> None:
|
||
season_label = (season_label_override or "").strip() or (f"Staffel {season_number}" if season_number > 0 else "")
|
||
episode_label = (episode_label_override or "").strip() or (
|
||
f"Episode {episode_number}" if episode_number > 0 else ""
|
||
)
|
||
_log(f"Play (URL) anfordern: {plugin_name} / {title} / {season_label} / {episode_label} / {episode_url}")
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Wiedergabe", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
return
|
||
|
||
available_hosters: list[str] = []
|
||
hoster_getter = getattr(plugin, "available_hosters_for_url", None)
|
||
if callable(hoster_getter):
|
||
try:
|
||
with _busy_dialog("Hoster werden geladen..."):
|
||
available_hosters = list(hoster_getter(episode_url) or [])
|
||
except Exception as exc:
|
||
_log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
|
||
selected_hoster: str | None = None
|
||
autoplay = _get_setting_bool("autoplay_enabled", default=False)
|
||
preferred = _get_setting_string("preferred_hoster").strip()
|
||
if available_hosters:
|
||
if autoplay and preferred:
|
||
pref_lower = preferred.casefold()
|
||
for hoster in available_hosters:
|
||
if pref_lower in hoster.casefold():
|
||
selected_hoster = hoster
|
||
break
|
||
if selected_hoster is None:
|
||
selected_hoster = available_hosters[0]
|
||
_log(f"Autoplay: bevorzugter Hoster '{preferred}' nicht gefunden, nutze '{selected_hoster}'.", xbmc.LOGDEBUG)
|
||
if selected_hoster is None and len(available_hosters) == 1:
|
||
selected_hoster = available_hosters[0]
|
||
elif selected_hoster is None:
|
||
selected_index = xbmcgui.Dialog().select("Hoster waehlen", available_hosters)
|
||
if selected_index is None or selected_index < 0:
|
||
_log("Play abgebrochen (kein Hoster gewählt).", xbmc.LOGDEBUG)
|
||
return
|
||
selected_hoster = available_hosters[selected_index]
|
||
|
||
preferred_setter = getattr(plugin, "set_preferred_hosters", None)
|
||
restore_hosters: list[str] | None = None
|
||
if selected_hoster and callable(preferred_setter):
|
||
current = getattr(plugin, "_preferred_hosters", None)
|
||
if isinstance(current, list):
|
||
restore_hosters = list(current)
|
||
preferred_setter([selected_hoster])
|
||
|
||
try:
|
||
link_getter = getattr(plugin, "stream_link_for_url", None)
|
||
if not callable(link_getter):
|
||
xbmcgui.Dialog().notification("Wiedergabe", "Diese Funktion wird von der Quelle nicht unterstuetzt.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
return
|
||
link = link_getter(episode_url)
|
||
if not link:
|
||
_log("Kein Stream gefunden.", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
return
|
||
_log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
|
||
final_link = _resolve_stream_with_retry(plugin, link)
|
||
if not final_link:
|
||
return
|
||
finally:
|
||
if restore_hosters is not None and callable(preferred_setter):
|
||
preferred_setter(restore_hosters)
|
||
|
||
display_title = f"{title} - S{season_number:02d}E{episode_number:02d}" if season_number and episode_number else title
|
||
info_labels, art, cast = _tmdb_labels_and_art(title)
|
||
info_labels = dict(info_labels or {})
|
||
info_labels.setdefault("mediatype", "episode")
|
||
info_labels.setdefault("tvshowtitle", title)
|
||
if season_number > 0:
|
||
info_labels["season"] = str(season_number)
|
||
if episode_number > 0:
|
||
info_labels["episode"] = str(episode_number)
|
||
display_title = _label_with_duration(display_title, info_labels)
|
||
|
||
title_key = (title or "").strip().casefold()
|
||
_tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key, 0)
|
||
_imdb_id = ""
|
||
_kind = _tmdb_cache_get(_MEDIA_TYPE_CACHE, title_key, "tv") if _tmdb_id else "tv"
|
||
if _tmdb_id:
|
||
_imdb_id = _fetch_and_cache_imdb_id(title_key, _tmdb_id, _kind)
|
||
_set_trakt_ids_property(title, _tmdb_id, _imdb_id)
|
||
trakt_media: dict[str, object] = {
|
||
"title": title, "tmdb_id": _tmdb_id, "imdb_id": _imdb_id, "kind": _kind,
|
||
"season": season_number or 0, "episode": episode_number or 0,
|
||
}
|
||
|
||
_play_final_link(
|
||
final_link,
|
||
display_title=display_title,
|
||
info_labels=info_labels,
|
||
art=art,
|
||
cast=cast,
|
||
resolve_handle=resolve_handle,
|
||
trakt_media=trakt_media,
|
||
)
|
||
_track_playback_and_update_state_async(
|
||
_playstate_key(plugin_name=plugin_name, title=title, season=season_label, episode=episode_label)
|
||
)
|
||
|
||
|
||
def _parse_params() -> dict[str, str]:
|
||
"""Parst Kodi-Plugin-Parameter aus `sys.argv[2]`."""
|
||
if len(sys.argv) <= 2 or not sys.argv[2]:
|
||
return {}
|
||
raw_params = parse_qs(sys.argv[2].lstrip("?"), keep_blank_values=True)
|
||
return {key: values[0] for key, values in raw_params.items()}
|
||
|
||
|
||
def _show_year_menu(plugin_name: str) -> None:
|
||
"""Zeigt verfuegbare Erscheinungsjahre eines Plugins (Capability: year_filter)."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Jahr", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "years_available", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: Nach Jahr")
|
||
try:
|
||
years = list(getter() or [])
|
||
except Exception as exc:
|
||
_log(f"Jahre konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
for year in years:
|
||
_add_directory_item(handle, str(year), "year_titles_page",
|
||
{"plugin": plugin_name, "year": str(year), "page": "1"}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_year_titles_page(plugin_name: str, year: str, page: int = 1) -> None:
|
||
"""Zeigt Titel eines bestimmten Erscheinungsjahres."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "titles_for_year", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {year} (Seite {page})")
|
||
_set_content(handle, "movies")
|
||
if page > 1:
|
||
_add_directory_item(handle, "Vorherige Seite", "year_titles_page",
|
||
{"plugin": plugin_name, "year": year, "page": str(page - 1)}, is_folder=True)
|
||
try:
|
||
titles = _run_with_progress("Jahr", f"{plugin_name}: {year} wird geladen...",
|
||
lambda: list(getter(year, page) or []))
|
||
except Exception as exc:
|
||
_log(f"Jahr-Titel konnten nicht geladen werden ({plugin_name}/{year} p{page}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
for title in titles:
|
||
_add_directory_item(handle, title, "seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=True)
|
||
if titles:
|
||
_add_directory_item(handle, "Naechste Seite", "year_titles_page",
|
||
{"plugin": plugin_name, "year": year, "page": str(page + 1)}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_country_menu(plugin_name: str) -> None:
|
||
"""Zeigt verfuegbare Produktionslaender eines Plugins (Capability: country_filter)."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Land", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "countries_available", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: Nach Land")
|
||
try:
|
||
countries = list(getter() or [])
|
||
except Exception as exc:
|
||
_log(f"Laender konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
for country in countries:
|
||
_add_directory_item(handle, str(country), "country_titles_page",
|
||
{"plugin": plugin_name, "country": str(country), "page": "1"}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_country_titles_page(plugin_name: str, country: str, page: int = 1) -> None:
|
||
"""Zeigt Titel eines bestimmten Produktionslandes."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "titles_for_country", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {country} (Seite {page})")
|
||
_set_content(handle, "movies")
|
||
if page > 1:
|
||
_add_directory_item(handle, "Vorherige Seite", "country_titles_page",
|
||
{"plugin": plugin_name, "country": country, "page": str(page - 1)}, is_folder=True)
|
||
try:
|
||
titles = _run_with_progress("Land", f"{plugin_name}: {country} wird geladen...",
|
||
lambda: list(getter(country, page) or []))
|
||
except Exception as exc:
|
||
_log(f"Land-Titel konnten nicht geladen werden ({plugin_name}/{country} p{page}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
for title in titles:
|
||
_add_directory_item(handle, title, "seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=True)
|
||
if titles:
|
||
_add_directory_item(handle, "Naechste Seite", "country_titles_page",
|
||
{"plugin": plugin_name, "country": country, "page": str(page + 1)}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_collections_menu(plugin_name: str) -> None:
|
||
"""Zeigt Sammlungen/Filmreihen eines Plugins (Capability: collections)."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Sammlungen", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "collections", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: Sammlungen")
|
||
try:
|
||
cols = list(getter() or [])
|
||
except Exception as exc:
|
||
_log(f"Sammlungen konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
for col in cols:
|
||
_add_directory_item(handle, str(col), "collection_titles_page",
|
||
{"plugin": plugin_name, "collection": str(col), "page": "1"}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_collection_titles_page(plugin_name: str, collection: str, page: int = 1) -> None:
|
||
"""Zeigt Titel einer Sammlung/Filmreihe."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "titles_for_collection", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {collection}")
|
||
_set_content(handle, "movies")
|
||
if page > 1:
|
||
_add_directory_item(handle, "Vorherige Seite", "collection_titles_page",
|
||
{"plugin": plugin_name, "collection": collection, "page": str(page - 1)}, is_folder=True)
|
||
try:
|
||
titles = _run_with_progress("Sammlung", f"{plugin_name}: {collection} wird geladen...",
|
||
lambda: list(getter(collection, page) or []))
|
||
except Exception as exc:
|
||
_log(f"Sammlungs-Titel konnten nicht geladen werden ({plugin_name}/{collection}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
direct_play = bool(plugin_name.casefold() == "einschalten"
|
||
and _get_setting_bool("einschalten_enable_playback", default=False))
|
||
for title in titles:
|
||
_add_directory_item(handle, title, "play_movie" if direct_play else "seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=not direct_play)
|
||
if titles:
|
||
_add_directory_item(handle, "Naechste Seite", "collection_titles_page",
|
||
{"plugin": plugin_name, "collection": collection, "page": str(page + 1)}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_tags_menu(plugin_name: str) -> None:
|
||
"""Zeigt Schlagworte/Tags eines Plugins (Capability: tags)."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcgui.Dialog().notification("Schlagworte", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "tags", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: Schlagworte")
|
||
try:
|
||
tag_list = list(getter() or [])
|
||
except Exception as exc:
|
||
_log(f"Tags konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
for tag in sorted(tag_list, key=lambda t: str(t).casefold()):
|
||
_add_directory_item(handle, str(tag), "tag_titles_page",
|
||
{"plugin": plugin_name, "tag": str(tag), "page": "1"}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_tag_titles_page(plugin_name: str, tag: str, page: int = 1) -> None:
|
||
"""Zeigt Titel zu einem Schlagwort/Tag."""
|
||
handle = _get_handle()
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
getter = getattr(plugin, "titles_for_tag", None)
|
||
if not callable(getter):
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {tag} (Seite {page})")
|
||
_set_content(handle, "tvshows")
|
||
if page > 1:
|
||
_add_directory_item(handle, "Vorherige Seite", "tag_titles_page",
|
||
{"plugin": plugin_name, "tag": tag, "page": str(page - 1)}, is_folder=True)
|
||
try:
|
||
titles = _run_with_progress("Schlagwort", f"{plugin_name}: {tag} wird geladen...",
|
||
lambda: list(getter(tag, page) or []))
|
||
except Exception as exc:
|
||
_log(f"Tag-Titel konnten nicht geladen werden ({plugin_name}/{tag} p{page}): {exc}", xbmc.LOGWARNING)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
titles = [str(t).strip() for t in titles if t and str(t).strip()]
|
||
for title in titles:
|
||
_add_directory_item(handle, title, "seasons",
|
||
{"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)},
|
||
is_folder=True)
|
||
if titles:
|
||
_add_directory_item(handle, "Naechste Seite", "tag_titles_page",
|
||
{"plugin": plugin_name, "tag": tag, "page": str(page + 1)}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _play_random_title(plugin_name: str) -> None:
|
||
"""Oeffnet einen zufaelligen Titel direkt (Capability: random)."""
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
if plugin is None:
|
||
return
|
||
getter = getattr(plugin, "random_title", None)
|
||
if not callable(getter):
|
||
return
|
||
try:
|
||
title = getter()
|
||
except Exception as exc:
|
||
_log(f"Zufaelliger Titel konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||
return
|
||
if title:
|
||
_show_seasons(plugin_name, str(title), "")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ---------------------------------------------------------------------------
|
||
# Trakt-Aktionen
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _trakt_authorize() -> None:
|
||
"""Startet den OAuth Device Auth Flow."""
|
||
client = _trakt_get_client()
|
||
if not client:
|
||
xbmcgui.Dialog().notification("Trakt", "Client ID/Secret fehlt – bitte in den Einstellungen eintragen.",
|
||
xbmcgui.NOTIFICATION_INFO, 4000)
|
||
return
|
||
code = client.device_code_request()
|
||
if not code:
|
||
xbmcgui.Dialog().notification("Trakt", "Fehler bei der Autorisierung.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
return
|
||
dialog = xbmcgui.DialogProgress()
|
||
dialog.create("Trakt Autorisierung",
|
||
f"Gehe zu {code.verification_url}\nund gib diesen Code ein:\n\n{code.user_code}")
|
||
token = None
|
||
start = time.time()
|
||
while time.time() - start < code.expires_in:
|
||
if dialog.iscanceled():
|
||
break
|
||
time.sleep(code.interval)
|
||
from core.trakt import TraktClient
|
||
# Einzelversuch (kein internes Polling – wir steuern die Schleife selbst)
|
||
tmp_client = TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG))
|
||
status, payload = tmp_client._post("/oauth/device/token", {
|
||
"code": code.device_code,
|
||
"client_id": TRAKT_CLIENT_ID,
|
||
"client_secret": TRAKT_CLIENT_SECRET,
|
||
})
|
||
if status == 200 and isinstance(payload, dict):
|
||
from core.trakt import TraktToken
|
||
token = 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)),
|
||
)
|
||
break
|
||
if status in (404, 410, 418):
|
||
break
|
||
progress = int((time.time() - start) / code.expires_in * 100)
|
||
dialog.update(min(progress, 99))
|
||
dialog.close()
|
||
if token:
|
||
_trakt_save_token(token)
|
||
xbmcgui.Dialog().notification("Trakt", "Erfolgreich autorisiert!", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
else:
|
||
xbmcgui.Dialog().notification("Trakt", "Autorisierung fehlgeschlagen oder abgebrochen.",
|
||
xbmcgui.NOTIFICATION_INFO, 3000)
|
||
|
||
|
||
def _show_trakt_watchlist(media_type: str = "") -> None:
|
||
handle = _get_handle()
|
||
token = _trakt_get_valid_token()
|
||
client = _trakt_get_client()
|
||
if not token or not client:
|
||
xbmcgui.Dialog().notification("Trakt", "Nicht autorisiert.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
if not media_type:
|
||
_add_directory_item(handle, "Filme", "trakt_watchlist", {"type": "movies"}, is_folder=True)
|
||
_add_directory_item(handle, "Serien", "trakt_watchlist", {"type": "shows"}, is_folder=True)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
_set_content(handle, "tvshows")
|
||
items = client.get_watchlist(token, media_type=media_type)
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk([i.title for i in items]) if _tmdb_enabled() else {}
|
||
for item in items:
|
||
label = f"{item.title} ({item.year})" if item.year else item.title
|
||
|
||
tmdb_info, art, _ = tmdb_prefetched.get(item.title, ({}, {}, []))
|
||
info_labels: dict[str, object] = dict(tmdb_info)
|
||
info_labels["title"] = label
|
||
info_labels["tvshowtitle"] = item.title
|
||
if item.year:
|
||
info_labels["year"] = item.year
|
||
info_labels["mediatype"] = "tvshow"
|
||
|
||
_add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art)
|
||
if not items:
|
||
xbmcgui.Dialog().notification("Trakt", "Watchlist ist leer.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_trakt_history(page: int = 1) -> None:
|
||
handle = _get_handle()
|
||
token = _trakt_get_valid_token()
|
||
client = _trakt_get_client()
|
||
if not token or not client:
|
||
xbmcgui.Dialog().notification("Trakt", "Nicht autorisiert.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, "Trakt: Zuletzt gesehen")
|
||
_set_content(handle, "episodes")
|
||
|
||
items = client.get_history(token, page=page, limit=LIST_PAGE_SIZE)
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(list(dict.fromkeys(i.title for i in items))) if _tmdb_enabled() else {}
|
||
for item in items:
|
||
is_episode = item.media_type == "episode" and item.season and item.episode
|
||
|
||
# Label mit Episodentitel wenn vorhanden
|
||
if is_episode:
|
||
ep_title = item.episode_title or f"Episode {item.episode}"
|
||
label = f"{item.title} – S{item.season:02d}E{item.episode:02d}: {ep_title}"
|
||
elif item.year:
|
||
label = f"{item.title} ({item.year})"
|
||
else:
|
||
label = item.title
|
||
|
||
# Artwork: Trakt-Bilder als Basis, TMDB ergänzt fehlende Keys
|
||
art: dict[str, str] = {}
|
||
if item.episode_thumb:
|
||
art["thumb"] = item.episode_thumb
|
||
if item.show_fanart:
|
||
art["fanart"] = item.show_fanart
|
||
if item.show_poster:
|
||
art["poster"] = item.show_poster
|
||
_, tmdb_art, _ = tmdb_prefetched.get(item.title, ({}, {}, []))
|
||
for _k, _v in tmdb_art.items():
|
||
art.setdefault(_k, _v)
|
||
|
||
# Info-Labels
|
||
info_labels: dict[str, object] = {}
|
||
info_labels["title"] = item.episode_title if is_episode and item.episode_title else label
|
||
info_labels["tvshowtitle"] = item.title
|
||
if item.year:
|
||
info_labels["year"] = item.year
|
||
if is_episode:
|
||
info_labels["season"] = item.season
|
||
info_labels["episode"] = item.episode
|
||
if item.episode_overview:
|
||
info_labels["plot"] = item.episode_overview
|
||
info_labels["mediatype"] = "episode" if is_episode else "tvshow"
|
||
|
||
# Kontextmenue: Zur Watchlist hinzufuegen
|
||
ctx: list[tuple[str, str]] = []
|
||
if item.ids and (item.ids.tmdb or item.ids.imdb):
|
||
wl_type = "movie" if item.media_type == "movie" else "tv"
|
||
wl_params = urlencode({"action": "trakt_watchlist_add", "type": wl_type,
|
||
"tmdb_id": str(item.ids.tmdb), "imdb_id": item.ids.imdb})
|
||
ctx.append(("Zur Trakt-Watchlist hinzufuegen",
|
||
f"RunPlugin({sys.argv[0]}?{wl_params})"))
|
||
|
||
# Navigation: Episoden direkt abspielen, Serien zur Staffelauswahl
|
||
_add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art, context_menu=ctx or None)
|
||
|
||
if len(items) >= LIST_PAGE_SIZE:
|
||
_add_directory_item(handle, "Naechste Seite >>", "trakt_history", {"page": str(page + 1)}, is_folder=True)
|
||
if not items and page == 1:
|
||
xbmcgui.Dialog().notification("Trakt", "Keine History vorhanden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_trakt_upcoming() -> None:
|
||
"""Zeigt anstehende Episoden der Watchlist-Serien (Trakt-Kalender, naechste 14 Tage)."""
|
||
handle = _get_handle()
|
||
token = _trakt_get_valid_token()
|
||
client = _trakt_get_client()
|
||
if not token or not client:
|
||
xbmcgui.Dialog().notification("Trakt", "Nicht autorisiert.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, "Trakt: Upcoming")
|
||
_set_content(handle, "tvshows")
|
||
|
||
try:
|
||
from core.trakt import TraktCalendarItem as _TCI # noqa: F401
|
||
items = client.get_calendar(token, days=14)
|
||
except Exception as exc:
|
||
_log(f"Trakt Calendar fehlgeschlagen: {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Trakt", "Kalender konnte nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
if not items:
|
||
xbmcgui.Dialog().notification("Trakt", "Keine anstehenden Folgen in den naechsten 14 Tagen.", xbmcgui.NOTIFICATION_INFO, 4000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
from datetime import datetime, date as _date
|
||
|
||
_WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||
today = _date.today()
|
||
|
||
# Datum pro Item berechnen und nach Datum gruppieren
|
||
dated_items: list[tuple[_date, object]] = []
|
||
for item in items:
|
||
airdate = today
|
||
if item.first_aired:
|
||
try:
|
||
dt = datetime.fromisoformat(item.first_aired.replace("Z", "+00:00"))
|
||
airdate = dt.astimezone(tz=None).date()
|
||
except Exception:
|
||
pass
|
||
dated_items.append((airdate, item))
|
||
|
||
# TMDB-Artwork fuer alle Serien parallel prefetchen (dedupliziert)
|
||
show_titles = list(dict.fromkeys(item.show_title for _, item in dated_items))
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(show_titles) if _tmdb_enabled() else {}
|
||
|
||
last_date: _date | None = None
|
||
for airdate, item in dated_items:
|
||
# Datums-Ueberschrift einfuegen
|
||
if airdate != last_date:
|
||
last_date = airdate
|
||
delta = (airdate - today).days
|
||
if delta == 0:
|
||
heading = "Heute"
|
||
elif delta == 1:
|
||
heading = "Morgen"
|
||
elif 2 <= delta <= 6:
|
||
heading = _WEEKDAYS[airdate.weekday()]
|
||
else:
|
||
heading = f"{_WEEKDAYS[airdate.weekday()]} {airdate.strftime('%d.%m.')}"
|
||
sep = xbmcgui.ListItem(label=f"[B]{heading}[/B]")
|
||
sep.setProperty("IsPlayable", "false")
|
||
try:
|
||
_apply_video_info(sep, {"title": heading, "mediatype": "video"}, None)
|
||
except Exception:
|
||
pass
|
||
xbmcplugin.addDirectoryItem(handle=handle, url="", listitem=sep, isFolder=False)
|
||
|
||
# Episoden-Label mit Titel
|
||
ep_title = item.episode_title or ""
|
||
label = f"{item.show_title} \u2013 S{item.season:02d}E{item.episode:02d}"
|
||
if ep_title:
|
||
label = f"{label}: {ep_title}"
|
||
|
||
info_labels: dict[str, object] = {
|
||
"title": label,
|
||
"tvshowtitle": item.show_title,
|
||
"season": item.season,
|
||
"episode": item.episode,
|
||
"mediatype": "episode",
|
||
}
|
||
if item.show_year:
|
||
info_labels["year"] = item.show_year
|
||
if item.episode_overview:
|
||
info_labels["plot"] = item.episode_overview
|
||
if ep_title:
|
||
info_labels["tagline"] = ep_title
|
||
|
||
# Artwork: Trakt-Bilder als Basis, TMDB ergaenzt fehlende Keys
|
||
art: dict[str, str] = {}
|
||
if item.show_poster:
|
||
art["thumb"] = item.show_poster
|
||
art["poster"] = item.show_poster
|
||
if item.episode_thumb:
|
||
art["fanart"] = item.episode_thumb
|
||
elif item.show_fanart:
|
||
art["fanart"] = item.show_fanart
|
||
_, tmdb_art, _ = tmdb_prefetched.get(item.show_title, ({}, {}, []))
|
||
for _k, _v in tmdb_art.items():
|
||
art.setdefault(_k, _v)
|
||
|
||
action = "search"
|
||
params: dict[str, str] = {"query": item.show_title}
|
||
|
||
_add_directory_item(handle, label, action, params, is_folder=True, info_labels=info_labels, art=art)
|
||
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
def _show_trakt_continue_watching() -> None:
|
||
"""Zeigt die naechste ungesehene Folge je Serie aus der Trakt-History."""
|
||
handle = _get_handle()
|
||
token = _trakt_get_valid_token()
|
||
client = _trakt_get_client()
|
||
if not token or not client:
|
||
xbmcgui.Dialog().notification("Trakt", "Nicht autorisiert.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
xbmcplugin.setPluginCategory(handle, "Weiterschauen")
|
||
_set_content(handle, "episodes")
|
||
|
||
try:
|
||
history = client.get_history(token, media_type="episodes", limit=100)
|
||
except Exception as exc:
|
||
_log(f"Trakt History fehlgeschlagen: {exc}", xbmc.LOGWARNING)
|
||
xbmcgui.Dialog().notification("Trakt", "History konnte nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
# Pro Serie nur den zuletzt gesehenen Eintrag behalten (History ist absteigend sortiert)
|
||
seen: dict[str, object] = {}
|
||
for item in history:
|
||
if item.title and item.title not in seen:
|
||
seen[item.title] = item
|
||
|
||
if not seen:
|
||
xbmcgui.Dialog().notification("Trakt", "Keine History vorhanden.", xbmcgui.NOTIFICATION_INFO, 3000)
|
||
xbmcplugin.endOfDirectory(handle)
|
||
return
|
||
|
||
# TMDB-Artwork fuer alle Serien parallel prefetchen
|
||
tmdb_prefetched = _tmdb_labels_and_art_bulk(list(seen.keys())) if _tmdb_enabled() else {}
|
||
|
||
for last in seen.values():
|
||
next_season = last.season
|
||
next_ep = last.episode + 1
|
||
|
||
label = f"{last.title} \u2013 S{next_season:02d}E{next_ep:02d}"
|
||
sub = f"(zuletzt: S{last.season:02d}E{last.episode:02d})"
|
||
display_label = f"{label} {sub}"
|
||
|
||
info_labels: dict[str, object] = {
|
||
"title": display_label,
|
||
"tvshowtitle": last.title,
|
||
"season": next_season,
|
||
"episode": next_ep,
|
||
"mediatype": "episode",
|
||
}
|
||
if last.year:
|
||
info_labels["year"] = last.year
|
||
|
||
_, art, _ = tmdb_prefetched.get(last.title, ({}, {}, []))
|
||
_add_directory_item(handle, display_label, "search", {"query": last.title}, is_folder=True, info_labels=info_labels, art=art)
|
||
|
||
xbmcplugin.endOfDirectory(handle)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Route-Handler – registriert über @_router.route("action")
|
||
# Jeder Handler nimmt params: dict[str, str] und delegiert an den
|
||
# zuständigen _show_*- oder _play_*-Handler.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@_router.route("search")
|
||
def _route_search(params: dict[str, str]) -> None:
|
||
query = params.get("query", "").strip()
|
||
if query:
|
||
_show_search_results(query)
|
||
else:
|
||
_show_search()
|
||
|
||
|
||
@_router.route("plugin_menu")
|
||
def _route_plugin_menu(params: dict[str, str]) -> None:
|
||
_show_plugin_menu(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("plugin_search")
|
||
def _route_plugin_search(params: dict[str, str]) -> None:
|
||
_show_plugin_search(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("genres")
|
||
def _route_genres(params: dict[str, str]) -> None:
|
||
_show_genres(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("latest_titles")
|
||
def _route_latest_titles(params: dict[str, str]) -> None:
|
||
_show_latest_titles(params.get("plugin", ""), _parse_positive_int(params.get("page", "1"), default=1))
|
||
|
||
|
||
@_router.route("new_titles")
|
||
def _route_new_titles(params: dict[str, str]) -> None:
|
||
_show_new_titles(params.get("plugin", ""), _parse_positive_int(params.get("page", "1"), default=1))
|
||
|
||
|
||
@_router.route("latest_episodes")
|
||
def _route_latest_episodes(params: dict[str, str]) -> None:
|
||
_show_latest_episodes(params.get("plugin", ""), _parse_positive_int(params.get("page", "1"), default=1))
|
||
|
||
|
||
@_router.route("genre_series")
|
||
def _route_genre_series(params: dict[str, str]) -> None:
|
||
_show_genre_series(params.get("plugin", ""), params.get("genre", ""))
|
||
|
||
|
||
@_router.route("genre_titles_page")
|
||
def _route_genre_titles_page(params: dict[str, str]) -> None:
|
||
_show_genre_titles_page(
|
||
params.get("plugin", ""), params.get("genre", ""),
|
||
_parse_positive_int(params.get("page", "1"), default=1),
|
||
)
|
||
|
||
|
||
|
||
@_router.route("alpha_index")
|
||
def _route_alpha_index(params: dict[str, str]) -> None:
|
||
_show_alpha_index(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("alpha_titles_page")
|
||
def _route_alpha_titles_page(params: dict[str, str]) -> None:
|
||
_show_alpha_titles_page(
|
||
params.get("plugin", ""), params.get("letter", ""),
|
||
_parse_positive_int(params.get("page", "1"), default=1),
|
||
)
|
||
|
||
|
||
@_router.route("series_catalog")
|
||
def _route_series_catalog(params: dict[str, str]) -> None:
|
||
_show_series_catalog(params.get("plugin", ""), _parse_positive_int(params.get("page", "1"), default=1))
|
||
|
||
|
||
@_router.route("genre_series_group")
|
||
def _route_genre_series_group(params: dict[str, str]) -> None:
|
||
_show_genre_series_group(
|
||
params.get("plugin", ""), params.get("genre", ""), params.get("group", ""),
|
||
_parse_positive_int(params.get("page", "1"), default=1),
|
||
)
|
||
|
||
|
||
@_router.route("popular")
|
||
def _route_popular(params: dict[str, str]) -> None:
|
||
_show_popular(params.get("plugin") or None, _parse_positive_int(params.get("page", "1"), default=1))
|
||
|
||
|
||
@_router.route("settings")
|
||
def _route_settings(params: dict[str, str]) -> None:
|
||
_open_settings()
|
||
|
||
|
||
@_router.route("check_updates")
|
||
def _route_check_updates(params: dict[str, str]) -> None:
|
||
_run_update_check()
|
||
|
||
|
||
@_router.route("apply_update_channel")
|
||
def _route_apply_update_channel(params: dict[str, str]) -> None:
|
||
_apply_update_channel()
|
||
|
||
|
||
@_router.route("select_update_version")
|
||
def _route_select_update_version(params: dict[str, str]) -> None:
|
||
_show_version_selector()
|
||
|
||
|
||
@_router.route("install_resolveurl")
|
||
def _route_install_resolveurl(params: dict[str, str]) -> None:
|
||
_ensure_resolveurl_installed(force=True, silent=False)
|
||
|
||
|
||
@_router.route("install_ytdlp")
|
||
def _route_install_ytdlp(params: dict[str, str]) -> None:
|
||
_ensure_ytdlp_installed(force=True, silent=False)
|
||
|
||
|
||
@_router.route("choose_source")
|
||
def _route_choose_source(params: dict[str, str]) -> None:
|
||
_show_choose_source(params.get("title", ""), params.get("plugins", ""))
|
||
|
||
|
||
@_router.route("seasons")
|
||
def _route_seasons(params: dict[str, str]) -> None:
|
||
_show_seasons(params.get("plugin", ""), params.get("title", ""), params.get("series_url", ""))
|
||
|
||
|
||
@_router.route("episodes")
|
||
def _route_episodes(params: dict[str, str]) -> None:
|
||
_show_episodes(
|
||
params.get("plugin", ""), params.get("title", ""),
|
||
params.get("season", ""), params.get("series_url", ""),
|
||
)
|
||
|
||
|
||
@_router.route("play_episode")
|
||
def _route_play_episode(params: dict[str, str]) -> None:
|
||
_play_episode(
|
||
params.get("plugin", ""), params.get("title", ""),
|
||
params.get("season", ""), params.get("episode", ""),
|
||
forced_hoster=params.get("hoster", ""),
|
||
episode_url=params.get("url", ""),
|
||
series_url=params.get("series_url", ""),
|
||
resolve_handle=_get_handle(),
|
||
)
|
||
|
||
|
||
@_router.route("play_movie")
|
||
def _route_play_movie(params: dict[str, str]) -> None:
|
||
plugin_name = params.get("plugin", "")
|
||
title = params.get("title", "")
|
||
series_url = params.get("series_url", "")
|
||
if series_url:
|
||
plugin = _discover_plugins().get(plugin_name)
|
||
remember_fn = getattr(plugin, "remember_series_url", None) if plugin is not None else None
|
||
if callable(remember_fn):
|
||
try:
|
||
remember_fn(title, series_url)
|
||
except Exception:
|
||
pass
|
||
# Einschalten: Filme haben kein Staffel-/Episodenkonzept → Stream → Titel.
|
||
if (plugin_name or "").casefold() == "einschalten":
|
||
_play_episode(plugin_name, title, "Stream", title, resolve_handle=_get_handle())
|
||
else:
|
||
_play_episode(plugin_name, title, "Film", "Stream", resolve_handle=_get_handle())
|
||
|
||
|
||
@_router.route("play_episode_url")
|
||
def _route_play_episode_url(params: dict[str, str]) -> None:
|
||
_play_episode_url(
|
||
params.get("plugin", ""),
|
||
title=params.get("title", ""),
|
||
season_number=_parse_positive_int(params.get("season", "0"), default=0),
|
||
episode_number=_parse_positive_int(params.get("episode", "0"), default=0),
|
||
episode_url=params.get("url", ""),
|
||
resolve_handle=_get_handle(),
|
||
)
|
||
|
||
|
||
@_router.route("play")
|
||
def _route_play(params: dict[str, str]) -> None:
|
||
link = params.get("url", "")
|
||
if link:
|
||
_play_final_link(link, resolve_handle=_get_handle())
|
||
|
||
|
||
@_router.route("year_menu")
|
||
def _route_year_menu(params: dict[str, str]) -> None:
|
||
_show_year_menu(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("year_titles_page")
|
||
def _route_year_titles_page(params: dict[str, str]) -> None:
|
||
_show_year_titles_page(
|
||
params.get("plugin", ""), params.get("year", ""),
|
||
_parse_positive_int(params.get("page", "1"), default=1),
|
||
)
|
||
|
||
|
||
@_router.route("country_menu")
|
||
def _route_country_menu(params: dict[str, str]) -> None:
|
||
_show_country_menu(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("country_titles_page")
|
||
def _route_country_titles_page(params: dict[str, str]) -> None:
|
||
_show_country_titles_page(
|
||
params.get("plugin", ""), params.get("country", ""),
|
||
_parse_positive_int(params.get("page", "1"), default=1),
|
||
)
|
||
|
||
|
||
@_router.route("collections_menu")
|
||
def _route_collections_menu(params: dict[str, str]) -> None:
|
||
_show_collections_menu(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("collection_titles_page")
|
||
def _route_collection_titles_page(params: dict[str, str]) -> None:
|
||
_show_collection_titles_page(
|
||
params.get("plugin", ""), params.get("collection", ""),
|
||
_parse_positive_int(params.get("page", "1"), default=1),
|
||
)
|
||
|
||
|
||
@_router.route("tags_menu")
|
||
def _route_tags_menu(params: dict[str, str]) -> None:
|
||
_show_tags_menu(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("tag_titles_page")
|
||
def _route_tag_titles_page(params: dict[str, str]) -> None:
|
||
_show_tag_titles_page(
|
||
params.get("plugin", ""), params.get("tag", ""),
|
||
_parse_positive_int(params.get("page", "1"), default=1),
|
||
)
|
||
|
||
|
||
@_router.route("random_title")
|
||
def _route_random_title(params: dict[str, str]) -> None:
|
||
_play_random_title(params.get("plugin", ""))
|
||
|
||
|
||
@_router.route("trakt_auth")
|
||
def _route_trakt_auth(params: dict[str, str]) -> None:
|
||
_trakt_authorize()
|
||
xbmcplugin.endOfDirectory(_get_handle(), succeeded=False)
|
||
|
||
|
||
@_router.route("trakt_watchlist")
|
||
def _route_trakt_watchlist(params: dict[str, str]) -> None:
|
||
_show_trakt_watchlist(params.get("type", ""))
|
||
|
||
|
||
@_router.route("trakt_history")
|
||
def _route_trakt_history(params: dict[str, str]) -> None:
|
||
_show_trakt_history(_parse_positive_int(params.get("page", "1"), default=1))
|
||
|
||
|
||
@_router.route("trakt_upcoming")
|
||
def _route_trakt_upcoming(params: dict[str, str]) -> None:
|
||
_show_trakt_upcoming()
|
||
|
||
|
||
@_router.route("trakt_continue")
|
||
def _route_trakt_continue(params: dict[str, str]) -> None:
|
||
_show_trakt_continue_watching()
|
||
|
||
|
||
@_router.route("trakt_watchlist_add")
|
||
def _route_trakt_watchlist_add(params: dict[str, str]) -> None:
|
||
client = _trakt_get_client()
|
||
token = _trakt_get_valid_token()
|
||
if client and token:
|
||
try:
|
||
tmdb_id = int(params.get("tmdb_id", "0") or "0")
|
||
except ValueError:
|
||
tmdb_id = 0
|
||
ok = client.add_to_watchlist(
|
||
token,
|
||
media_type=params.get("type", "movie"),
|
||
tmdb_id=tmdb_id,
|
||
imdb_id=params.get("imdb_id", ""),
|
||
)
|
||
msg = "Zur Watchlist hinzugefuegt" if ok else "Fehler beim Hinzufuegen"
|
||
else:
|
||
msg = "Trakt nicht autorisiert"
|
||
xbmcgui.Dialog().notification("Trakt", msg, xbmcgui.NOTIFICATION_INFO, 3000)
|
||
|
||
|
||
@_router.route("trakt_watchlist_remove")
|
||
def _route_trakt_watchlist_remove(params: dict[str, str]) -> None:
|
||
client = _trakt_get_client()
|
||
token = _trakt_get_valid_token()
|
||
if client and token:
|
||
try:
|
||
tmdb_id = int(params.get("tmdb_id", "0") or "0")
|
||
except ValueError:
|
||
tmdb_id = 0
|
||
ok = client.remove_from_watchlist(
|
||
token,
|
||
media_type=params.get("type", "movie"),
|
||
tmdb_id=tmdb_id,
|
||
imdb_id=params.get("imdb_id", ""),
|
||
)
|
||
msg = "Von Watchlist entfernt" if ok else "Fehler beim Entfernen"
|
||
else:
|
||
msg = "Trakt nicht autorisiert"
|
||
xbmcgui.Dialog().notification("Trakt", msg, xbmcgui.NOTIFICATION_INFO, 3000)
|
||
|
||
|
||
@_router.fallback()
|
||
def _route_fallback(params: dict[str, str]) -> None:
|
||
_show_root_menu()
|
||
|
||
|
||
def run() -> None:
|
||
params = _parse_params()
|
||
action = params.get("action")
|
||
_log(f"Action: {action}", xbmc.LOGDEBUG)
|
||
_maybe_run_auto_update_check(action)
|
||
_maybe_auto_install_resolveurl(action)
|
||
_router.dispatch(action=action, params=params)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run()
|