diff --git a/addon/default.py b/addon/default.py index 78fd185..73e8433 100644 --- a/addon/default.py +++ b/addon/default.py @@ -1214,7 +1214,7 @@ def _show_plugin_search_results(plugin_name: str, query: str) -> None: except Exception: pass raise - results = [str(t).strip() for t in (results or []) if t and str(t).strip()] + results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()]) results.sort(key=lambda value: value.casefold()) use_source, show_tmdb, prefer_source = _metadata_policy( @@ -1406,6 +1406,33 @@ def _series_url_params(plugin: BasisPlugin, title: str) -> dict[str, str]: 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() @@ -1453,7 +1480,7 @@ def _show_search_results(query: str) -> None: pass _log(f"Suche fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) continue - results = [str(t).strip() for t in (results or []) if t and str(t).strip()] + results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()]) _log(f"Treffer ({plugin_name}): {len(results)}", xbmc.LOGDEBUG) use_source, show_tmdb, prefer_source = _metadata_policy( plugin_name, plugin, allow_tmdb=_tmdb_enabled() @@ -1537,6 +1564,73 @@ def _show_search_results(query: str) -> None: 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}") @@ -1553,60 +1647,6 @@ def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None: except Exception: pass - # Einschalten liefert Filme. Für Playback soll nach dem Öffnen des Titels direkt ein - # einzelnes abspielbares Item angezeigt werden: -> ( abspielbar). - # Wichtig: ohne zusätzliche Netzwerkanfragen (sonst bleibt Kodi ggf. im Busy-Spinner hängen). - if (plugin_name or "").casefold() == "einschalten" and _get_setting_bool("einschalten_enable_playback", default=False): - xbmcplugin.setPluginCategory(handle, title) - _set_content(handle, "movies") - playstate = _title_playstate(plugin_name, title) - info_labels: dict[str, object] = {"title": title, "mediatype": "movie"} - info_labels = _apply_playstate_to_info(info_labels, playstate) - display_label = _label_with_playstate(title, playstate) - movie_params = {"plugin": plugin_name, "title": title} - if series_url: - movie_params["series_url"] = series_url - _add_directory_item( - handle, - display_label, - "play_movie", - movie_params, - is_folder=False, - info_labels=info_labels, - ) - xbmcplugin.endOfDirectory(handle) - return - - # Optional: Plugins können schnell (ohne Detail-Request) sagen, ob ein Titel ein Film ist. - # Dann zeigen wir direkt ein einzelnes abspielbares Item: -> (). - is_movie = getattr(plugin, "is_movie", None) - if callable(is_movie): - try: - if bool(is_movie(title)): - xbmcplugin.setPluginCategory(handle, title) - _set_content(handle, "movies") - playstate = _title_playstate(plugin_name, title) - info_labels: dict[str, object] = {"title": title, "mediatype": "movie"} - info_labels = _apply_playstate_to_info(info_labels, playstate) - display_label = _label_with_playstate(title, playstate) - movie_params = {"plugin": plugin_name, "title": title} - if series_url: - movie_params["series_url"] = series_url - else: - movie_params.update(_series_url_params(plugin, title)) - _add_directory_item( - handle, - display_label, - "play_movie", - movie_params, - is_folder=False, - info_labels=info_labels, - ) - xbmcplugin.endOfDirectory(handle) - return - except Exception: - pass - use_source, show_tmdb, _prefer_source = _metadata_policy( plugin_name, plugin, allow_tmdb=_tmdb_enabled() ) @@ -1636,6 +1676,26 @@ def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None: 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})") @@ -3210,12 +3270,29 @@ def _track_playback_and_update_state(key: str) -> None: pass +def _track_playback_and_update_state_async(key: str) -> None: + """Startet Playstate-Tracking im Hintergrund, damit die UI nicht blockiert.""" + key = (key or "").strip() + if not key: + return + + def _worker() -> None: + try: + _track_playback_and_update_state(key) + except Exception: + pass + + worker = threading.Thread(target=_worker, name="viewit-playstate-tracker", daemon=True) + worker.start() + + 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, @@ -3260,10 +3337,16 @@ def _play_episode( _log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) selected_hoster: str | None = None + forced_hoster = (forced_hoster or "").strip() if available_hosters: - if len(available_hosters) == 1: + if forced_hoster: + for hoster in available_hosters: + if hoster.casefold() == forced_hoster.casefold(): + selected_hoster = hoster + break + if selected_hoster is None and len(available_hosters) == 1: selected_hoster = available_hosters[0] - else: + 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) @@ -3308,7 +3391,7 @@ def _play_episode( cast=cast, resolve_handle=resolve_handle, ) - _track_playback_and_update_state( + _track_playback_and_update_state_async( _playstate_key(plugin_name=plugin_name, title=title, season=season, episode=episode) ) @@ -3396,7 +3479,7 @@ def _play_episode_url( cast=cast, resolve_handle=resolve_handle, ) - _track_playback_and_update_state( + _track_playback_and_update_state_async( _playstate_key(plugin_name=plugin_name, title=title, season=season_label, episode=episode_label) ) @@ -3496,6 +3579,7 @@ def run() -> None: 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(), diff --git a/addon/plugins/aniworld_plugin.py b/addon/plugins/aniworld_plugin.py index f6c74fd..a73f79c 100644 --- a/addon/plugins/aniworld_plugin.py +++ b/addon/plugins/aniworld_plugin.py @@ -754,6 +754,7 @@ class AniworldPlugin(BasisPlugin): def __init__(self) -> None: self._anime_results: Dict[str, SeriesResult] = {} self._title_url_cache: Dict[str, str] = self._load_title_url_cache() + self._title_meta: Dict[str, tuple[str, str]] = {} self._genre_names_cache: Optional[List[str]] = None self._season_cache: Dict[str, List[SeasonInfo]] = {} self._season_links_cache: Dict[str, List[SeasonInfo]] = {} @@ -818,8 +819,64 @@ class AniworldPlugin(BasisPlugin): changed = True if changed and persist: self._save_title_url_cache() + if description: + old_plot, old_poster = self._title_meta.get(title, ("", "")) + self._title_meta[title] = (description.strip() or old_plot, old_poster) return changed + def _store_title_meta(self, title: str, *, plot: str = "", poster: str = "") -> None: + title = (title or "").strip() + if not title: + return + old_plot, old_poster = self._title_meta.get(title, ("", "")) + merged_plot = (plot or old_plot or "").strip() + merged_poster = (poster or old_poster or "").strip() + self._title_meta[title] = (merged_plot, merged_poster) + + def _extract_series_metadata(self, soup: BeautifulSoupT) -> tuple[str, str]: + if not soup: + return "", "" + plot = "" + poster = "" + + for selector in ("meta[property='og:description']", "meta[name='description']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + plot = content + break + if not plot: + for selector in (".series-description", ".seri_des", ".description", "article p"): + node = soup.select_one(selector) + if node is None: + continue + text = (node.get_text(" ", strip=True) or "").strip() + if text: + plot = text + break + + for selector in ("meta[property='og:image']", "meta[name='twitter:image']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + poster = _absolute_url(content) + break + if not poster: + for selector in ("img.seriesCoverBox", ".seriesCoverBox img", "img[alt][src]"): + image = soup.select_one(selector) + if image is None: + continue + value = (image.get("data-src") or image.get("src") or "").strip() + if value: + poster = _absolute_url(value) + break + + return plot, poster + @staticmethod def _season_links_cache_name(series_url: str) -> str: digest = hashlib.sha1((series_url or "").encode("utf-8")).hexdigest()[:20] @@ -951,6 +1008,40 @@ class AniworldPlugin(BasisPlugin): return None + def metadata_for(self, title: str) -> tuple[dict[str, str], dict[str, str], list[object] | None]: + title = (title or "").strip() + if not title: + return {}, {}, None + + info: dict[str, str] = {"title": title} + art: dict[str, str] = {} + cached_plot, cached_poster = self._title_meta.get(title, ("", "")) + if cached_plot: + info["plot"] = cached_plot + if cached_poster: + art = {"thumb": cached_poster, "poster": cached_poster} + if "plot" in info and art: + return info, art, None + + series = self._find_series_by_title(title) + if series is None or not series.url: + return info, art, None + if series.description and "plot" not in info: + info["plot"] = series.description + + try: + soup = _get_soup(series.url, session=get_requests_session("aniworld", headers=HEADERS)) + plot, poster = self._extract_series_metadata(soup) + except Exception: + plot, poster = "", "" + + if plot: + info["plot"] = plot + if poster: + art = {"thumb": poster, "poster": poster} + self._store_title_meta(title, plot=info.get("plot", ""), poster=poster) + return info, art, None + def _ensure_popular(self) -> List[SeriesResult]: if self._popular_cache is not None: return list(self._popular_cache) diff --git a/addon/plugins/filmpalast_plugin.py b/addon/plugins/filmpalast_plugin.py index cff8a7f..721421f 100644 --- a/addon/plugins/filmpalast_plugin.py +++ b/addon/plugins/filmpalast_plugin.py @@ -244,6 +244,7 @@ class FilmpalastPlugin(BasisPlugin): def __init__(self) -> None: self._title_to_url: Dict[str, str] = {} + self._title_meta: Dict[str, tuple[str, str]] = {} self._series_entries: Dict[str, Dict[int, Dict[int, EpisodeEntry]]] = {} self._hoster_cache: Dict[str, Dict[str, str]] = {} self._genre_to_url: Dict[str, str] = {} @@ -722,6 +723,59 @@ class FilmpalastPlugin(BasisPlugin): return hit.url return "" + def _store_title_meta(self, title: str, *, plot: str = "", poster: str = "") -> None: + title = (title or "").strip() + if not title: + return + old_plot, old_poster = self._title_meta.get(title, ("", "")) + merged_plot = (plot or old_plot or "").strip() + merged_poster = (poster or old_poster or "").strip() + self._title_meta[title] = (merged_plot, merged_poster) + + def _extract_detail_metadata(self, soup: BeautifulSoupT) -> tuple[str, str]: + if not soup: + return "", "" + plot = "" + poster = "" + + for selector in ("meta[property='og:description']", "meta[name='description']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + plot = content + break + if not plot: + for selector in (".toggle-content .coverDetails", ".entry-content p", "article p"): + node = soup.select_one(selector) + if node is None: + continue + text = (node.get_text(" ", strip=True) or "").strip() + if text and len(text) > 40: + plot = text + break + + for selector in ("meta[property='og:image']", "meta[name='twitter:image']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + poster = _absolute_url(content) + break + if not poster: + for selector in ("img.cover", "article img", ".entry-content img"): + image = soup.select_one(selector) + if image is None: + continue + value = (image.get("data-src") or image.get("src") or "").strip() + if value: + poster = _absolute_url(value) + break + + return plot, poster + def remember_series_url(self, title: str, series_url: str) -> None: title = (title or "").strip() series_url = (series_url or "").strip() @@ -742,6 +796,52 @@ class FilmpalastPlugin(BasisPlugin): return _series_hint_value(series_key) return "" + def metadata_for(self, title: str) -> tuple[dict[str, str], dict[str, str], list[object] | None]: + title = (title or "").strip() + if not title: + return {}, {}, None + + info: dict[str, str] = {"title": title} + art: dict[str, str] = {} + cached_plot, cached_poster = self._title_meta.get(title, ("", "")) + if cached_plot: + info["plot"] = cached_plot + if cached_poster: + art = {"thumb": cached_poster, "poster": cached_poster} + if "plot" in info and art: + return info, art, None + + detail_url = self._ensure_title_url(title) + if not detail_url: + series_key = self._series_key_for_title(title) or self._ensure_series_entries_for_title(title) + if series_key: + seasons = self._series_entries.get(series_key, {}) + first_entry: Optional[EpisodeEntry] = None + for season_number in sorted(seasons.keys()): + episodes = seasons.get(season_number, {}) + for episode_number in sorted(episodes.keys()): + first_entry = episodes.get(episode_number) + if first_entry is not None: + break + if first_entry is not None: + break + detail_url = first_entry.url if first_entry is not None else "" + if not detail_url: + return info, art, None + + try: + soup = _get_soup(detail_url, session=get_requests_session("filmpalast", headers=HEADERS)) + plot, poster = self._extract_detail_metadata(soup) + except Exception: + plot, poster = "", "" + + if plot: + info["plot"] = plot + if poster: + art = {"thumb": poster, "poster": poster} + self._store_title_meta(title, plot=info.get("plot", ""), poster=poster) + return info, art, None + def is_movie(self, title: str) -> bool: title = (title or "").strip() if not title: diff --git a/addon/plugins/topstreamfilm_plugin.py b/addon/plugins/topstreamfilm_plugin.py index b272439..7cd8680 100644 --- a/addon/plugins/topstreamfilm_plugin.py +++ b/addon/plugins/topstreamfilm_plugin.py @@ -97,6 +97,7 @@ class SearchHit: title: str url: str description: str = "" + poster: str = "" def _normalize_search_text(value: str) -> str: @@ -149,6 +150,7 @@ class TopstreamfilmPlugin(BasisPlugin): self._season_to_episode_numbers: Dict[tuple[str, str], List[int]] = {} self._episode_title_by_number: Dict[tuple[str, int, int], str] = {} self._detail_html_cache: Dict[str, str] = {} + self._title_meta: Dict[str, tuple[str, str]] = {} self._popular_cache: List[str] | None = None self._default_preferred_hosters: List[str] = list(DEFAULT_PREFERRED_HOSTERS) self._preferred_hosters: List[str] = list(self._default_preferred_hosters) @@ -429,6 +431,7 @@ class TopstreamfilmPlugin(BasisPlugin): continue seen.add(hit.title) self._title_to_url[hit.title] = hit.url + self._store_title_meta(hit.title, plot=hit.description, poster=hit.poster) titles.append(hit.title) if titles: self._save_title_url_cache() @@ -487,6 +490,69 @@ class TopstreamfilmPlugin(BasisPlugin): except Exception: return "" + def _pick_image_from_node(self, node: Any) -> str: + if node is None: + return "" + image = node.select_one("img") + if image is None: + return "" + for attr in ("data-src", "src"): + value = (image.get(attr) or "").strip() + if value and "lazy_placeholder" not in value.casefold(): + return self._absolute_external_url(value, base=self._get_base_url()) + srcset = (image.get("data-srcset") or image.get("srcset") or "").strip() + if srcset: + first = srcset.split(",")[0].strip().split(" ", 1)[0].strip() + if first: + return self._absolute_external_url(first, base=self._get_base_url()) + return "" + + def _store_title_meta(self, title: str, *, plot: str = "", poster: str = "") -> None: + title = (title or "").strip() + if not title: + return + old_plot, old_poster = self._title_meta.get(title, ("", "")) + merged_plot = (plot or old_plot or "").strip() + merged_poster = (poster or old_poster or "").strip() + self._title_meta[title] = (merged_plot, merged_poster) + + def _extract_detail_metadata(self, soup: BeautifulSoupT) -> tuple[str, str]: + if not soup: + return "", "" + plot = "" + poster = "" + for selector in ("meta[property='og:description']", "meta[name='description']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + plot = content + break + if not plot: + candidates: list[str] = [] + for paragraph in soup.select("article p, .TPost p, .Description p, .entry-content p"): + text = (paragraph.get_text(" ", strip=True) or "").strip() + if len(text) >= 60: + candidates.append(text) + if candidates: + plot = max(candidates, key=len) + + for selector in ("meta[property='og:image']", "meta[name='twitter:image']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + poster = self._absolute_external_url(content, base=self._get_base_url()) + break + if not poster: + for selector in ("article", ".TPost", ".entry-content"): + poster = self._pick_image_from_node(soup.select_one(selector)) + if poster: + break + return plot, poster + def _clear_stream_index_for_title(self, title: str) -> None: for key in list(self._season_to_episode_numbers.keys()): if key[0] == title: @@ -721,7 +787,17 @@ class TopstreamfilmPlugin(BasisPlugin): continue if is_movie_hint: self._movie_title_hint.add(title) - hits.append(SearchHit(title=title, url=self._absolute_url(href), description="")) + description_tag = item.select_one(".TPMvCn .Description, .Description, .entry-summary") + description = (description_tag.get_text(" ", strip=True) or "").strip() if description_tag else "" + poster = self._pick_image_from_node(item) + hits.append( + SearchHit( + title=title, + url=self._absolute_url(href), + description=description, + poster=poster, + ) + ) return hits def is_movie(self, title: str) -> bool: @@ -794,6 +870,7 @@ class TopstreamfilmPlugin(BasisPlugin): continue seen.add(hit.title) self._title_to_url[hit.title] = hit.url + self._store_title_meta(hit.title, plot=hit.description, poster=hit.poster) titles.append(hit.title) if titles: self._save_title_url_cache() @@ -905,7 +982,8 @@ class TopstreamfilmPlugin(BasisPlugin): self._movie_title_hint.add(title) description_tag = item.select_one(".TPMvCn .Description") description = description_tag.get_text(" ", strip=True) if description_tag else "" - hit = SearchHit(title=title, url=self._absolute_url(href), description=description) + poster = self._pick_image_from_node(item) + hit = SearchHit(title=title, url=self._absolute_url(href), description=description, poster=poster) if _matches_query(query, title=hit.title, description=hit.description): hits.append(hit) @@ -918,11 +996,41 @@ class TopstreamfilmPlugin(BasisPlugin): continue seen.add(hit.title) self._title_to_url[hit.title] = hit.url + self._store_title_meta(hit.title, plot=hit.description, poster=hit.poster) titles.append(hit.title) self._save_title_url_cache() _emit_progress(progress_callback, f"Fertig: {len(titles)} Treffer", 95) return titles + def metadata_for(self, title: str) -> tuple[dict[str, str], dict[str, str], list[object] | None]: + title = (title or "").strip() + if not title: + return {}, {}, None + + info: dict[str, str] = {"title": title} + art: dict[str, str] = {} + + cached_plot, cached_poster = self._title_meta.get(title, ("", "")) + if cached_plot: + info["plot"] = cached_plot + if cached_poster: + art = {"thumb": cached_poster, "poster": cached_poster} + + if "plot" in info and art: + return info, art, None + + soup = self._get_detail_soup(title) + if soup is None: + return info, art, None + + plot, poster = self._extract_detail_metadata(soup) + if plot: + info["plot"] = plot + if poster: + art = {"thumb": poster, "poster": poster} + self._store_title_meta(title, plot=plot, poster=poster) + return info, art, None + def genres(self) -> List[str]: if not REQUESTS_AVAILABLE or BeautifulSoup is None: return []