diff --git a/addon/core/trakt.py b/addon/core/trakt.py index aeccf39..1fe4bfa 100644 --- a/addon/core/trakt.py +++ b/addon/core/trakt.py @@ -54,12 +54,26 @@ class TraktMediaIds: class TraktItem: title: str year: int - media_type: str # "movie" oder "show" + media_type: str # "movie", "show" oder "episode" ids: TraktMediaIds = field(default_factory=TraktMediaIds) season: int = 0 episode: int = 0 watched_at: str = "" poster: str = "" + episode_title: str = "" # Episodentitel (extended=full) + episode_overview: str = "" # Episoden-Inhaltsangabe (extended=full) + episode_thumb: str = "" # Screenshot-URL (extended=images) + show_poster: str = "" # Serien-Poster-URL (extended=images) + show_fanart: str = "" # Serien-Fanart-URL (extended=images) + + +@dataclass(frozen=True) +class TraktEpisodeMeta: + """Metadaten einer einzelnen Episode (aus extended=full,images).""" + title: str + overview: str + runtime_minutes: int + thumb: str # Screenshot-URL (https://) @dataclass(frozen=True) @@ -71,9 +85,27 @@ class TraktCalendarItem: season: int episode: int episode_title: str + episode_overview: str # Episoden-Inhaltsangabe (extended=full) + episode_thumb: str # Screenshot-URL (https://) + show_poster: str # Poster-URL (https://) + show_fanart: str # Fanart-URL (https://) first_aired: str # ISO-8601, z.B. "2026-03-02T02:00:00.000Z" +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _trakt_image_url(raw: str) -> str: + """Stellt https:// vor relative Trakt-Bild-URLs.""" + if not raw: + return "" + raw = raw.strip() + if raw.startswith("http"): + return raw + return f"https://{raw}" + + # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- @@ -332,7 +364,7 @@ class TraktClient: path = "/users/me/history" if media_type in ("movies", "shows", "episodes"): path = f"{path}/{media_type}" - path = f"{path}?page={page}&limit={limit}" + path = f"{path}?page={page}&limit={limit}&extended=full,images" status, payload = self._get(path, token=token) if status != 200 or not isinstance(payload, list): return [] @@ -351,7 +383,7 @@ class TraktClient: if not start_date: from datetime import date start_date = date.today().strftime("%Y-%m-%d") - path = f"/calendars/my/shows/{start_date}/{days}" + path = f"/calendars/my/shows/{start_date}/{days}?extended=full,images" status, payload = self._get(path, token=token) if status != 200 or not isinstance(payload, list): return [] @@ -362,6 +394,13 @@ class TraktClient: show = entry.get("show") or {} ep = entry.get("episode") or {} show_ids = self._parse_ids(show.get("ids") or {}) + ep_images = ep.get("images") or {} + show_images = show.get("images") or {} + + def _first(img_dict: dict, key: str) -> str: + imgs = img_dict.get(key) or [] + return _trakt_image_url(imgs[0]) if imgs else "" + items.append(TraktCalendarItem( show_title=str(show.get("title", "") or ""), show_year=int(show.get("year", 0) or 0), @@ -369,10 +408,75 @@ class TraktClient: season=int(ep.get("season", 0) or 0), episode=int(ep.get("number", 0) or 0), episode_title=str(ep.get("title", "") or ""), + episode_overview=str(ep.get("overview", "") or ""), + episode_thumb=_first(ep_images, "screenshot"), + show_poster=_first(show_images, "poster"), + show_fanart=_first(show_images, "fanart"), first_aired=str(entry.get("first_aired", "") or ""), )) return items + def search_show(self, query: str) -> str: + """GET /search/show?query=... – gibt slug des ersten Treffers zurück, sonst ''.""" + from urllib.parse import urlencode + path = f"/search/show?{urlencode({'query': query, 'limit': 1})}" + status, payload = self._get(path) + if status != 200 or not isinstance(payload, list) or not payload: + return "" + show = (payload[0] or {}).get("show") or {} + ids = show.get("ids") or {} + return str(ids.get("slug") or ids.get("trakt") or "") + + def lookup_tv_season( + self, + show_id_or_slug: "str | int", + season_number: int, + *, + token: str = "", + ) -> "dict[int, TraktEpisodeMeta] | None": + """GET /shows/{id}/seasons/{n}/episodes?extended=full,images + Gibt episode_number -> TraktEpisodeMeta zurück, oder None bei Fehler. + """ + path = f"/shows/{show_id_or_slug}/seasons/{season_number}/episodes?extended=full,images" + status, payload = self._get(path, token=token) + if status != 200 or not isinstance(payload, list): + return None + result: "dict[int, TraktEpisodeMeta]" = {} + for entry in payload: + try: + ep_no = int(entry.get("number") or 0) + except Exception: + continue + if not ep_no: + continue + images = entry.get("images") or {} + screenshots = images.get("screenshot") or [] + thumb = _trakt_image_url(screenshots[0]) if screenshots else "" + result[ep_no] = TraktEpisodeMeta( + title=str(entry.get("title") or "").strip(), + overview=str(entry.get("overview") or "").strip(), + runtime_minutes=int(entry.get("runtime") or 0), + thumb=thumb, + ) + return result or None + + def get_episode_translation( + self, + show_id_or_slug: "str | int", + season: int, + episode: int, + language: str = "de", + ) -> "tuple[str, str]": + """GET /shows/{id}/seasons/{s}/episodes/{e}/translations/{lang} + Gibt (title, overview) in der Zielsprache zurück, oder ('', '') bei Fehler. + """ + path = f"/shows/{show_id_or_slug}/seasons/{season}/episodes/{episode}/translations/{language}" + status, payload = self._get(path) + if status != 200 or not isinstance(payload, list) or not payload: + return "", "" + first = payload[0] if payload else {} + return str(first.get("title") or ""), str(first.get("overview") or "") + # ------------------------------------------------------------------- # Parser # ------------------------------------------------------------------- @@ -417,6 +521,13 @@ class TraktClient: show = entry.get("show") or {} ep = entry.get("episode") or {} ids = self._parse_ids((show.get("ids") or {})) + ep_images = ep.get("images") or {} + show_images = show.get("images") or {} + + def _first_img(img_dict: dict, key: str) -> str: + imgs = img_dict.get(key) or [] + return _trakt_image_url(imgs[0]) if imgs else "" + result.append(TraktItem( title=str(show.get("title", "") or ""), year=int(show.get("year", 0) or 0), @@ -425,6 +536,11 @@ class TraktClient: season=int(ep.get("season", 0) or 0), episode=int(ep.get("number", 0) or 0), watched_at=watched_at, + episode_title=str(ep.get("title", "") or ""), + episode_overview=str(ep.get("overview", "") or ""), + episode_thumb=_first_img(ep_images, "screenshot"), + show_poster=_first_img(show_images, "poster"), + show_fanart=_first_img(show_images, "fanart"), )) else: media = entry.get("movie") or entry.get("show") or {} diff --git a/addon/default.py b/addon/default.py index b863283..9592e1f 100644 --- a/addon/default.py +++ b/addon/default.py @@ -131,6 +131,7 @@ _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 @@ -987,7 +988,9 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label _tmdb_labels_and_art(title) tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key) if not tmdb_id: - return {"title": episode_label}, {} + 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) @@ -1003,7 +1006,9 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label if cached_season is None: api_key = _get_setting_string("tmdb_api_key").strip() if not api_key or api_key == "None": - return {"title": episode_label}, {} + 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 @@ -1038,6 +1043,66 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label 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 [] @@ -4526,12 +4591,29 @@ def _show_trakt_watchlist(media_type: str = "") -> None: xbmcplugin.endOfDirectory(handle) return + _set_content(handle, "tvshows") items = client.get_watchlist(token, media_type=media_type) for item in items: - label = f"{item.title}" + label = f"{item.title} ({item.year})" if item.year else item.title + + tmdb_info, art, _ = _tmdb_labels_and_art(item.title) + info_labels: dict[str, object] = dict(tmdb_info) + info_labels["title"] = label + info_labels["tvshowtitle"] = item.title if item.year: - label = f"{item.title} ({item.year})" - _add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True) + info_labels["year"] = item.year + info_labels["mediatype"] = "tvshow" + + match = _trakt_find_in_plugins(item.title) + if match: + plugin_name, matched_title = match + action = "seasons" + params: dict[str, str] = {"plugin": plugin_name, "title": matched_title} + else: + action = "search" + params = {"query": item.title} + + _add_directory_item(handle, label, action, params, 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) @@ -4546,14 +4628,66 @@ def _show_trakt_history(page: int = 1) -> None: 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) for item in items: - label = item.title - if item.media_type == "episode" and item.season and item.episode: - label = f"{item.title} - S{item.season:02d}E{item.episode:02d}" + 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})" - _add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True) + 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_labels_and_art(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" + + # Navigation: Episoden direkt abspielen, Serien zur Staffelauswahl + match = _trakt_find_in_plugins(item.title) + if match: + plugin_name, matched_title = match + if is_episode: + action = "play_episode" + params: dict[str, str] = { + "plugin": plugin_name, + "title": matched_title, + "season": f"Staffel {item.season}", + "episode": f"Episode {item.episode}", + } + _add_directory_item(handle, label, action, params, is_folder=False, info_labels=info_labels, art=art) + else: + action = "seasons" + params = {"plugin": plugin_name, "title": matched_title} + _add_directory_item(handle, label, action, params, is_folder=True, info_labels=info_labels, art=art) + else: + _add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art) if len(items) >= LIST_PAGE_SIZE: _add_directory_item(handle, "Naechste Seite >>", "trakt_history", {"page": str(page + 1)}, is_folder=True) @@ -4613,8 +4747,20 @@ def _show_trakt_upcoming() -> None: } if item.show_year: info_labels["year"] = item.show_year + if item.episode_overview: + info_labels["plot"] = item.episode_overview - _, art, _ = _tmdb_labels_and_art(item.show_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_labels_and_art(item.show_title) + for _k, _v in tmdb_art.items(): + art.setdefault(_k, _v) match = _trakt_find_in_plugins(item.show_title) if match: @@ -4736,7 +4882,11 @@ def _show_trakt_continue_watching() -> None: @_router.route("search") def _route_search(params: dict[str, str]) -> None: - _show_search() + query = params.get("query", "").strip() + if query: + _show_search_results(query) + else: + _show_search() @_router.route("plugin_menu") diff --git a/addon/plugins/aniworld_plugin.py b/addon/plugins/aniworld_plugin.py index 8c15d18..35c2710 100644 --- a/addon/plugins/aniworld_plugin.py +++ b/addon/plugins/aniworld_plugin.py @@ -593,6 +593,7 @@ def resolve_redirect(target_url: str) -> Optional[str]: response = None try: response = session.get(normalized_url, headers=HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True) + response.raise_for_status() if response.url: _log_url(response.url, kind="RESOLVED") return response.url if response.url else None diff --git a/addon/plugins/dokustreams_plugin.py b/addon/plugins/dokustreams_plugin.py index fa27ed9..3afb0f0 100644 --- a/addon/plugins/dokustreams_plugin.py +++ b/addon/plugins/dokustreams_plugin.py @@ -304,7 +304,7 @@ class DokuStreamsPlugin(BasisPlugin): def clean_name(value: str) -> str: value = (value or "").strip() - return re.sub(r"\\s*\\(\\d+\\)\\s*$", "", value).strip() + return re.sub(r"\s*\(\d+\)\s*$", "", value).strip() def walk(ul, parents: List[str]) -> None: for li in ul.find_all("li", recursive=False): diff --git a/addon/plugins/filmpalast_plugin.py b/addon/plugins/filmpalast_plugin.py index e644591..60b35d9 100644 --- a/addon/plugins/filmpalast_plugin.py +++ b/addon/plugins/filmpalast_plugin.py @@ -728,7 +728,7 @@ class FilmpalastPlugin(BasisPlugin): def _extract_detail_metadata(self, soup: BeautifulSoupT) -> tuple[str, str, str]: if not soup: - return "", "" + return "", "", "" root = soup.select_one("div#content[role='main']") or soup detail = root.select_one("article.detail") or root plot = "" diff --git a/addon/plugins/serienstream_plugin.py b/addon/plugins/serienstream_plugin.py index 9e29a4e..f45aa16 100644 --- a/addon/plugins/serienstream_plugin.py +++ b/addon/plugins/serienstream_plugin.py @@ -865,7 +865,7 @@ def _extract_episodes(soup: BeautifulSoupT) -> list[EpisodeInfo]: onclick = (row.get("onclick") or "").strip() url = "" if onclick: - match = re.search(r"location=['\\\"]([^'\\\"]+)['\\\"]", onclick) + match = re.search(r"location=['\"]([^'\"]+)['\"]", onclick) if match: url = _absolute_url(match.group(1)) if not url: @@ -923,8 +923,6 @@ def _extract_episodes(soup: BeautifulSoupT) -> list[EpisodeInfo]: hosters=hosters, ) ) - if episodes: - return episodes return episodes diff --git a/scripts/build_install_addon.sh b/scripts/build_install_addon.sh index a0ab01c..84158ee 100755 --- a/scripts/build_install_addon.sh +++ b/scripts/build_install_addon.sh @@ -39,4 +39,18 @@ else find "${DEST_DIR}" -type f -name '*.pyc' -delete || true fi +# Auch nach ~/.kodi/addons/ deployen wenn vorhanden +KODI_ADDON_DIR="${HOME}/.kodi/addons/${ADDON_ID}" +if [[ -d "${HOME}/.kodi/addons" ]]; then + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete \ + --exclude '__pycache__/' \ + --exclude '*.pyc' \ + "${DEST_DIR}/" "${KODI_ADDON_DIR}/" + else + rm -rf "${KODI_ADDON_DIR}" + cp -a "${DEST_DIR}" "${KODI_ADDON_DIR}" + fi +fi + echo "${DEST_DIR}"