diff --git a/addon/addon.xml b/addon/addon.xml index 6687472..752ec06 100644 --- a/addon/addon.xml +++ b/addon/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/addon/core/updater.py b/addon/core/updater.py index e3e4af6..854645e 100644 --- a/addon/core/updater.py +++ b/addon/core/updater.py @@ -701,18 +701,11 @@ def show_version_selector() -> None: except Exception: pass - dialog = xbmcgui.Dialog() - try: - confirmed = dialog.yesno( - "Version installieren", - f"Installiert: {installed}", - f"Ausgewaehlt: {version}", - yeslabel="Installieren", - nolabel="Abbrechen", - ) - except TypeError: - confirmed = dialog.yesno("Version installieren", f"Installiert: {installed}", f"Ausgewaehlt: {version}") - if not confirmed: + action = xbmcgui.Dialog().select( + f"Version {version} installieren?", + ["Update installieren", "Abbrechen"], + ) + if action != 0: return show_notification("Updates", f"Installation gestartet: {version}", milliseconds=2500) diff --git a/addon/default.py b/addon/default.py index 8a5cc1e..7d5ed2c 100644 --- a/addon/default.py +++ b/addon/default.py @@ -1203,7 +1203,7 @@ def _normalize_update_info_url(raw: str) -> str: UPDATE_CHANNEL_MAIN = 0 UPDATE_CHANNEL_NIGHTLY = 1 UPDATE_CHANNEL_CUSTOM = 2 -AUTO_UPDATE_INTERVAL_SEC = 6 * 60 * 60 +_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" @@ -1644,6 +1644,17 @@ def _sync_update_version_settings() -> None: 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 installed != available: + _add_directory_item( + handle, + f"Update verfuegbar: {installed} -> {available}", + "select_update_version", + ) + _add_directory_item(handle, "Suche in allen Quellen", "search") plugins = _discover_plugins() @@ -3762,18 +3773,11 @@ def _show_version_selector() -> None: except Exception: pass - dialog = xbmcgui.Dialog() - try: - confirmed = dialog.yesno( - "Version installieren", - f"Installiert: {installed}", - f"Ausgewaehlt: {version}", - yeslabel="Installieren", - nolabel="Abbrechen", - ) - except TypeError: - confirmed = dialog.yesno("Version installieren", f"Installiert: {installed}", f"Ausgewaehlt: {version}") - if not confirmed: + 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) @@ -3794,7 +3798,9 @@ def _maybe_run_auto_update_check(action: str | None) -> None: return now = int(time.time()) last = _get_setting_int("auto_update_last_ts", default=0) - if last > 0 and (now - last) < AUTO_UPDATE_INTERVAL_SEC: + 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) @@ -4038,12 +4044,23 @@ def _play_episode( 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: @@ -4180,10 +4197,21 @@ def _play_episode_url( _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 len(available_hosters) == 1: + 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] - 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) diff --git a/addon/plugins/hdfilme_plugin.py b/addon/plugins/hdfilme_plugin.py index 1b62eff..6924fb3 100644 --- a/addon/plugins/hdfilme_plugin.py +++ b/addon/plugins/hdfilme_plugin.py @@ -38,7 +38,7 @@ HEADERS = { "Connection": "keep-alive", } -_URL_SEARCH = BASE_URL + "/?s={query}" +_URL_SEARCH = BASE_URL + "/?do=search&subaction=search&story={query}" _URL_NEW = BASE_URL + "/kinofilme-online/" _URL_SERIES = BASE_URL + "/serienstream-deutsch/" @@ -68,7 +68,7 @@ GENRE_SLUGS: dict[str, str] = { } # Hoster die übersprungen werden (kein Stream / nur Trailer) -_SKIP_LINK_KEYWORDS = ("youtube.com", "youtu.be") +_SKIP_LINK_KEYWORDS = ("youtube.com", "youtu.be", "hdfilme-tv.cc") ProgressCallback = Optional[Callable[[str, Optional[int]], Any]] @@ -122,6 +122,7 @@ class HdfilmePlugin(BasisPlugin): self._is_series: dict[str, bool] = {} self._title_meta: dict[str, tuple[str, str]] = {} # title → (plot, poster) self._episode_cache: dict[str, list[str]] = {} # detail_url → episode labels + self._preferred_hosters: list[str] = [] # ------------------------------------------------------------------ # Verfügbarkeit @@ -182,40 +183,64 @@ class HdfilmePlugin(BasisPlugin): titles.append(title) return titles + def _ensure_detail_url(self, title: str) -> str: + """Gibt die Detail-URL für einen Titel zurück. + + Sucht zuerst im Cache, dann live über die Suchfunktion. + """ + url = self._title_to_url.get(title, "") + if url: + return url + # Fallback: Live-Suche (nötig wenn Plugin-Instanz neu, Cache leer) + search_url = _URL_SEARCH.format(query=quote_plus(title.strip())) + soup = _get_soup(search_url) + if soup: + self._parse_entries(soup) + url = self._title_to_url.get(title, "") + return url + def _get_detail_soup(self, title: str) -> Any: """Lädt die Detailseite eines Titels.""" - url = self._title_to_url.get(title, "") + url = self._ensure_detail_url(title) if not url: return None return _get_soup(url) - def _extract_hoster_links(self, soup: Any, episode_id: str = "") -> list[str]: + def _extract_hoster_links(self, soup: Any, episode_id: str = "") -> dict[str, str]: """Extrahiert Hoster-Links aus einer Detailseite. + Gibt dict {Hoster-Name → URL} zurück. episode_id: wenn gesetzt, nur Links aus dem `
  • ` Block. """ if soup is None: - return [] - links: list[str] = [] + return {} + hosters: dict[str, str] = {} if episode_id: - # Serien-Episode: Links aus dem spezifischen Episode-Container container = soup.select_one(f"li#{episode_id}") if container is None: - return [] + return {} candidates = container.select("a[data-link]") else: - # Film: Links aus .mirrors candidates = soup.select(".mirrors [data-link]") + seen_names: set[str] = set() for el in candidates: href = _absolute_url((el.get("data-link") or "").strip()) if not href: continue if any(kw in href for kw in _SKIP_LINK_KEYWORDS): continue - links.append(href) - return links + name = el.get_text(strip=True) or "Hoster" + # Eindeutiger Name bei Duplikaten + base_name = name + i = 2 + while name in seen_names: + name = f"{base_name} {i}" + i += 1 + seen_names.add(name) + hosters[name] = href + return hosters def _staffel_nr(self, season: str) -> int: """Extrahiert die Staffelnummer aus einem Label wie 'Staffel 2'.""" @@ -270,7 +295,7 @@ class HdfilmePlugin(BasisPlugin): if season == "Film": return [title] - detail_url = self._title_to_url.get(title, "") + detail_url = self._ensure_detail_url(title) cached = self._episode_cache.get(detail_url) if cached is not None: return cached @@ -304,27 +329,40 @@ class HdfilmePlugin(BasisPlugin): self._episode_cache[detail_url] = result return result + def _hosters_for(self, title: str, season: str, episode: str) -> dict[str, str]: + """Gibt alle verfügbaren Hoster {Name → URL} für Titel/Staffel/Episode zurück.""" + soup = self._get_detail_soup(title) + if soup is None: + return {} + if season == "Film" or not self._is_series.get(title, False): + return self._extract_hoster_links(soup) + staffel_nr = self._staffel_nr(season) + ep_idx = self._ep_index(episode) + episode_id = f"serie-{staffel_nr}_{ep_idx}" + return self._extract_hoster_links(soup, episode_id) + + def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]: + return list(self._hosters_for(title, season, episode).keys()) + + def set_preferred_hosters(self, hosters: List[str]) -> None: + self._preferred_hosters = [h for h in hosters if h] + def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]: title = (title or "").strip() season = (season or "").strip() if not title: return None - - soup = self._get_detail_soup(title) - if soup is None: + hosters = self._hosters_for(title, season, episode) + if not hosters: return None - - if season == "Film" or not self._is_series.get(title, False): - # Film: .mirrors [data-link] - links = self._extract_hoster_links(soup) - else: - # Serie: Episode-Container - staffel_nr = self._staffel_nr(season) - ep_idx = self._ep_index(episode) - episode_id = f"serie-{staffel_nr}_{ep_idx}" - links = self._extract_hoster_links(soup, episode_id) - - return links[0] if links else None + # Bevorzugten Hoster nutzen falls gesetzt + for preferred in self._preferred_hosters: + key = preferred.casefold() + for name, url in hosters.items(): + if key in name.casefold() or key in url.casefold(): + return url + # Fallback: erster Hoster + return next(iter(hosters.values())) def resolve_stream_link(self, link: str) -> Optional[str]: link = (link or "").strip() @@ -390,7 +428,12 @@ class HdfilmePlugin(BasisPlugin): # Browsing # ------------------------------------------------------------------ - def latest_titles(self, page: int = 1) -> List[str]: + def new_titles(self) -> List[str]: + if not REQUESTS_AVAILABLE: + return [] + return self._parse_entries(_get_soup(_URL_NEW)) + + def new_titles_page(self, page: int = 1) -> List[str]: if not REQUESTS_AVAILABLE: return [] page = max(1, int(page or 1)) @@ -417,4 +460,4 @@ class HdfilmePlugin(BasisPlugin): return self._parse_entries(_get_soup(url)) def capabilities(self) -> set[str]: - return {"latest_titles", "popular_series", "genres"} + return {"new_titles", "popular_series", "genres"} diff --git a/addon/plugins/kkiste_plugin.py b/addon/plugins/kkiste_plugin.py index fd7a7e6..7a5b35d 100644 --- a/addon/plugins/kkiste_plugin.py +++ b/addon/plugins/kkiste_plugin.py @@ -93,6 +93,8 @@ class KKistePlugin(BasisPlugin): self._is_series: dict[str, bool] = {} # title → Staffelnummer (aus "Staffel N" extrahiert) self._season_nr: dict[str, int] = {} + # bevorzugte Hoster für Hoster-Dialog + self._preferred_hosters: list[str] = [] # ------------------------------------------------------------------ # Verfügbarkeit @@ -165,6 +167,24 @@ class KKistePlugin(BasisPlugin): self._title_meta[title] = (plot, poster, fanart) return title + def _ensure_watch_url(self, title: str) -> str: + """Gibt die Watch-URL zurück – lädt bei leerem Cache alle Titel nach.""" + url = self._title_to_watch_url.get(title, "") + if url: + return url + # Fallback: alle Titel laden und exact-match suchen + search_url = _URL_SEARCH.format(lang=_LANG) + data = self._get_json(search_url) + if isinstance(data, dict): + q_lower = title.lower() + for movie in (data.get("movies") or []): + if isinstance(movie, dict): + raw = str(movie.get("title") or "").strip() + if raw.lower() == q_lower: + self._cache_entry(movie) + return self._title_to_watch_url.get(title, "") + return "" + def _browse(self, content_type: str, order: str = "Trending") -> List[str]: url = _URL_BROWSE.format(lang=_LANG, type=content_type, order=order, page=1) data = self._get_json(url) @@ -175,6 +195,55 @@ class KKistePlugin(BasisPlugin): if isinstance(movie, dict) and (t := self._cache_entry(movie)) ] + def _hosters_for(self, title: str, season: str, episode: str) -> dict[str, str]: + """Gibt {Hoster-Name → URL} für Titel/Staffel/Episode zurück.""" + watch_url = self._ensure_watch_url(title) + if not watch_url: + return {} + data = self._get_json(watch_url) + if not isinstance(data, dict): + return {} + + streams = data.get("streams") or [] + hosters: dict[str, str] = {} + seen: set[str] = set() + + # Film vs Serie: relevante Streams filtern + if season == "Film": + target_streams = [s for s in streams if isinstance(s, dict)] + else: + m = re.search(r"\d+", episode or "") + ep_nr = int(m.group()) if m else None + if ep_nr is None: + return {} + target_streams = [ + s for s in streams + if isinstance(s, dict) and s.get("e") == ep_nr + ] + + for stream in target_streams: + src = str(stream.get("stream") or "").strip() + if not src: + continue + # Hoster-Name aus der Stream-URL extrahieren (nicht aus "source" – das ist die Aggregator-Quelle) + try: + from urllib.parse import urlparse + host = urlparse(src).hostname or "Hoster" + # Domain-Prefix entfernen (www.) + if host.startswith("www."): + host = host[4:] + except Exception: + host = "Hoster" + name = host + base_name = name + i = 2 + while name in seen: + name = f"{base_name} {i}" + i += 1 + seen.add(name) + hosters[name] = src + return hosters + # ------------------------------------------------------------------ # Pflicht-Methoden # ------------------------------------------------------------------ @@ -211,10 +280,14 @@ class KKistePlugin(BasisPlugin): return [] is_series = self._is_series.get(title) + if is_series is None: + # Cache leer (neue Instanz) – nachfüllen + self._ensure_watch_url(title) + is_series = self._is_series.get(title) + if is_series: season_nr = self._season_nr.get(title, 1) return [f"Staffel {season_nr}"] - # Film (oder unbekannt → Film-Fallback) return ["Film"] def episodes_for(self, title: str, season: str) -> List[str]: @@ -222,12 +295,11 @@ class KKistePlugin(BasisPlugin): if not title: return [] - # Film if season == "Film": return [title] # Serie: Episodenliste aus /data/watch/ laden - watch_url = self._title_to_watch_url.get(title, "") + watch_url = self._ensure_watch_url(title) if not watch_url: return [] @@ -247,7 +319,6 @@ class KKistePlugin(BasisPlugin): pass if not episode_nrs: - # Keine Episoden-Nummern → als Film behandeln return [title] return [f"Episode {nr}" for nr in sorted(episode_nrs)] @@ -256,48 +327,25 @@ class KKistePlugin(BasisPlugin): # Stream # ------------------------------------------------------------------ + def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]: + return list(self._hosters_for(title, season, episode).keys()) + + def set_preferred_hosters(self, hosters: List[str]) -> None: + self._preferred_hosters = [h for h in hosters if h] + def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]: title = (title or "").strip() - watch_url = self._title_to_watch_url.get(title, "") - if not watch_url: + hosters = self._hosters_for(title, season, episode) + if not hosters: return None - - data = self._get_json(watch_url) - if not isinstance(data, dict): - return None - - streams = data.get("streams") or [] - - if season == "Film": - # Film: Stream ohne Episode-Nummer bevorzugen - for stream in streams: - if isinstance(stream, dict) and stream.get("e") is None: - src = str(stream.get("stream") or "").strip() - if src: - return src - # Fallback: irgendeinen Stream - for stream in streams: - if isinstance(stream, dict): - src = str(stream.get("stream") or "").strip() - if src: - return src - else: - # Serie: Episodennummer extrahieren und matchen - m = re.search(r"\d+", episode or "") - if not m: - return None - ep_nr = int(m.group()) - for stream in streams: - if not isinstance(stream, dict): - continue - try: - if int(stream.get("e") or -1) == ep_nr: - src = str(stream.get("stream") or "").strip() - if src: - return src - except (ValueError, TypeError): - pass - return None + # Bevorzugten Hoster nutzen falls gesetzt + for preferred in self._preferred_hosters: + key = preferred.casefold() + for name, url in hosters.items(): + if key in name.casefold() or key in url.casefold(): + return url + # Fallback: erster Hoster + return next(iter(hosters.values())) def resolve_stream_link(self, link: str) -> Optional[str]: link = (link or "").strip() @@ -341,12 +389,23 @@ class KKistePlugin(BasisPlugin): # Browsing # ------------------------------------------------------------------ + def new_titles(self) -> List[str]: + return self._browse("movies", "new") + + def new_titles_page(self, page: int = 1) -> List[str]: + page = max(1, int(page or 1)) + url = _URL_BROWSE.format(lang=_LANG, type="movies", order="new", page=page) + data = self._get_json(url) + if not isinstance(data, dict): + return [] + return [ + t for movie in (data.get("movies") or []) + if isinstance(movie, dict) and (t := self._cache_entry(movie)) + ] + def popular_series(self) -> List[str]: return self._browse("tvseries", "views") - def latest_titles(self, page: int = 1) -> List[str]: - return self._browse("movies", "new") - def genres(self) -> List[str]: return sorted(GENRE_SLUGS.keys()) @@ -364,4 +423,4 @@ class KKistePlugin(BasisPlugin): ] def capabilities(self) -> set[str]: - return {"popular_series", "latest_titles", "genres"} + return {"popular_series", "new_titles", "genres"} diff --git a/addon/plugins/moflix_plugin.py b/addon/plugins/moflix_plugin.py index 7ca7255..e4dedb5 100644 --- a/addon/plugins/moflix_plugin.py +++ b/addon/plugins/moflix_plugin.py @@ -218,6 +218,8 @@ class MoflixPlugin(BasisPlugin): self._season_api_ids: dict[tuple[str, int], str] = {} # (title, season_nr) → Liste der Episode-Labels self._episode_labels: dict[tuple[str, int], list[str]] = {} + # bevorzugte Hoster für Hoster-Dialog + self._preferred_hosters: list[str] = [] # ------------------------------------------------------------------ # Verfügbarkeit @@ -510,57 +512,46 @@ class MoflixPlugin(BasisPlugin): # Stream # ------------------------------------------------------------------ - def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]: + def _videos_for(self, title: str, season: str, episode: str) -> list[dict]: + """Gibt die rohe videos[]-Liste für einen Titel/Staffel/Episode zurück.""" title = (title or "").strip() season = (season or "").strip() if season == "Film": - return self._stream_link_for_movie(title) + url = self._ensure_title_url(title) + if not url: + self._resolve_title(title) + url = self._ensure_title_url(title) + if not url: + return [] + data = self._get_json(url) + if not isinstance(data, dict): + return [] + return (data.get("title") or {}).get("videos") or [] season_nr = _extract_first_number(season) episode_nr = _extract_first_number(episode) if season_nr is None or episode_nr is None: - return None + return [] - # Season-API-ID ermitteln (mit Cache-Miss-Fallback) api_id = self._season_api_ids.get((title, season_nr), "") if not api_id: self.seasons_for(title) api_id = self._season_api_ids.get((title, season_nr), "") if not api_id: - return None + return [] - # Episoden-Detail laden – enthält videos[] mit src-URLs url = _URL_EPISODE.format(id=api_id, s=season_nr, e=episode_nr) data = self._get_json(url) if not isinstance(data, dict): - return None - videos = (data.get("episode") or {}).get("videos") or [] - return self._best_src_from_videos(videos) + return [] + return (data.get("episode") or {}).get("videos") or [] - def _stream_link_for_movie(self, title: str) -> Optional[str]: - """Wählt den besten src-Link eines Films aus den API-Videos.""" - url = self._ensure_title_url(title) - if not url: - self._resolve_title(title) - url = self._ensure_title_url(title) - if not url: - return None - data = self._get_json(url) - if not isinstance(data, dict): - return None - videos = (data.get("title") or {}).get("videos") or [] - return self._best_src_from_videos(videos) - - def _best_src_from_videos(self, videos: object) -> Optional[str]: - """Wählt die beste src-URL aus einer videos[]-Liste. - - Priorisiert bekannte auflösbare Hoster (vidara.to), - überspringt Domains die erfahrungsgemäß 403 liefern. - """ - preferred: list[str] = [] - fallback: list[str] = [] - for v in (videos if isinstance(videos, list) else []): + def _hosters_from_videos(self, videos: list) -> dict[str, str]: + """Konvertiert videos[] zu {Hoster-Name → src-URL}, mit Skip/Prefer-Logik.""" + hosters: dict[str, str] = {} + seen: set[str] = set() + for v in videos: if not isinstance(v, dict): continue src = _safe_str(v.get("src")) @@ -569,12 +560,44 @@ class MoflixPlugin(BasisPlugin): domain = urlparse(src).netloc.lstrip("www.") if domain in _VIDEO_SKIP_DOMAINS: continue + name = _normalize_video_name(_safe_str(v.get("name")), src) + if not name: + name = domain + base_name = name + i = 2 + while name in seen: + name = f"{base_name} {i}" + i += 1 + seen.add(name) + hosters[name] = src + return hosters + + def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]: + videos = self._videos_for(title, season, episode) + return list(self._hosters_from_videos(videos).keys()) + + def set_preferred_hosters(self, hosters: List[str]) -> None: + self._preferred_hosters = [h for h in hosters if h] + + def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]: + videos = self._videos_for(title, season, episode) + if not videos: + return None + hosters = self._hosters_from_videos(videos) + if not hosters: + return None + # Bevorzugten Hoster nutzen falls gesetzt + for preferred in self._preferred_hosters: + key = preferred.casefold() + for name, url in hosters.items(): + if key in name.casefold() or key in url.casefold(): + return url + # Fallback: Prefer-Domains zuerst, dann Rest + for url in hosters.values(): + domain = urlparse(url).netloc.lstrip("www.") if domain in _VIDEO_PREFER_DOMAINS: - preferred.append(src) - else: - fallback.append(src) - candidates = preferred + fallback - return candidates[0] if candidates else None + return url + return next(iter(hosters.values())) def _resolve_vidara(self, filecode: str) -> Optional[str]: """Löst einen vidara.to-Filecode über die vidara-API auf → HLS-URL.""" @@ -727,7 +750,10 @@ class MoflixPlugin(BasisPlugin): def popular_series(self) -> List[str]: return self._titles_from_channel("series") - def latest_titles(self, page: int = 1) -> List[str]: + def new_titles(self) -> List[str]: + return self._titles_from_channel("now-playing") + + def new_titles_page(self, page: int = 1) -> List[str]: return self._titles_from_channel("now-playing", page=page) def genres(self) -> List[str]: @@ -752,4 +778,4 @@ class MoflixPlugin(BasisPlugin): return self._titles_from_channel(slug, page=page) def capabilities(self) -> set[str]: - return {"popular_series", "latest_titles", "collections", "genres"} + return {"popular_series", "new_titles", "collections", "genres"} diff --git a/addon/plugins/serienstream_plugin.py b/addon/plugins/serienstream_plugin.py index 8cb6514..35293a6 100644 --- a/addon/plugins/serienstream_plugin.py +++ b/addon/plugins/serienstream_plugin.py @@ -57,6 +57,7 @@ else: # pragma: no cover SETTING_BASE_URL = "serienstream_base_url" +SETTING_CATALOG_SEARCH = "serienstream_catalog_search" DEFAULT_BASE_URL = "https://s.to" DEFAULT_PREFERRED_HOSTERS = ["voe"] DEFAULT_TIMEOUT = 20 @@ -710,36 +711,46 @@ def _store_catalog_index_in_cache(items: list[SeriesResult]) -> None: def search_series(query: str, *, progress_callback: ProgressCallback = None) -> list[SeriesResult]: - """Sucht Serien im (/serien)-Katalog nach Titel. Nutzt Cache + Ein-Pass-Filter.""" + """Sucht Serien. Katalog-Suche (vollstaendig) oder API-Suche (max 10) je nach Setting.""" _ensure_requests() if not _normalize_search_text(query): return [] - _emit_progress(progress_callback, "Pruefe Such-Cache", 15) - cached = _load_catalog_index_from_cache() - if cached is not None: - matched_from_cache = [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)] - _emit_progress(progress_callback, f"Cache-Treffer: {len(cached)}", 35) - if matched_from_cache: - return matched_from_cache - _emit_progress(progress_callback, "Lade Katalogseite", 42) - catalog_url = f"{_get_base_url()}/serien?by=genre" - items: list[SeriesResult] = [] - try: - # Bevorzugt den Soup-Helper, damit Tests HTML einfache injizieren koennen. - soup = _get_soup_simple(catalog_url) - items = _catalog_index_from_soup(soup) - except Exception: - body = _get_html_simple(catalog_url) - items = _extract_catalog_index_from_html(body, progress_callback=progress_callback) - if not items: - _emit_progress(progress_callback, "Fallback-Parser", 58) - soup = BeautifulSoup(body, "html.parser") + use_catalog = _get_setting_bool(SETTING_CATALOG_SEARCH, default=True) + + if use_catalog: + _emit_progress(progress_callback, "Pruefe Such-Cache", 15) + cached = _load_catalog_index_from_cache() + if cached is not None: + matched_from_cache = [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)] + _emit_progress(progress_callback, f"Cache-Treffer: {len(cached)}", 35) + if matched_from_cache: + return matched_from_cache + + _emit_progress(progress_callback, "Lade Katalogseite", 42) + catalog_url = f"{_get_base_url()}/serien?by=genre" + items: list[SeriesResult] = [] + try: + soup = _get_soup_simple(catalog_url) items = _catalog_index_from_soup(soup) - if items: - _store_catalog_index_in_cache(items) - _emit_progress(progress_callback, f"Filtere Treffer ({len(items)})", 70) - return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)] + except Exception: + body = _get_html_simple(catalog_url) + items = _extract_catalog_index_from_html(body, progress_callback=progress_callback) + if not items: + _emit_progress(progress_callback, "Fallback-Parser", 58) + soup = BeautifulSoup(body, "html.parser") + items = _catalog_index_from_soup(soup) + if items: + _store_catalog_index_in_cache(items) + _emit_progress(progress_callback, f"Filtere Treffer ({len(items)})", 70) + return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)] + + # API-Suche (primaer wenn Katalog deaktiviert, Fallback wenn Katalog leer) + _emit_progress(progress_callback, "API-Suche", 60) + api_results = _search_series_api(query) + if api_results: + _emit_progress(progress_callback, f"API-Treffer: {len(api_results)}", 80) + return api_results _emit_progress(progress_callback, "Server-Suche", 85) server_results = _search_series_server(query) diff --git a/addon/resources/settings.xml b/addon/resources/settings.xml index daacbaa..efe5a3e 100644 --- a/addon/resources/settings.xml +++ b/addon/resources/settings.xml @@ -2,6 +2,7 @@ + @@ -16,6 +17,8 @@ + + @@ -35,10 +38,16 @@ + + + + + + diff --git a/scripts/hooks/commit-msg b/scripts/hooks/commit-msg new file mode 100755 index 0000000..2d3383e --- /dev/null +++ b/scripts/hooks/commit-msg @@ -0,0 +1,28 @@ +#!/bin/bash +# commit-msg: Commit-Message als Changelog-Eintrag in CHANGELOG-DEV.md prependen (nur dev-Branch) + +branch=$(git symbolic-ref --short HEAD 2>/dev/null) +[[ "$branch" != "dev" ]] && exit 0 + +root=$(git rev-parse --show-toplevel) +cd "$root" + +msg=$(cat "$1") +version=$(grep -oP 'version="\K[0-9]+\.[0-9]+\.[0-9]+[^"]*' addon/addon.xml | head -1) +today=$(date +%Y-%m-%d) + +# Changelog-Eintrag aufbauen +# Jede nicht-leere Zeile der Commit-Message wird ein "- ..." Eintrag +{ + echo "## ${version} - ${today}" + echo "" + while IFS= read -r line; do + [[ -z "$line" ]] && continue + echo "- ${line}" + done <<< "$msg" + echo "" + cat CHANGELOG-DEV.md +} > /tmp/changelog_new.md + +mv /tmp/changelog_new.md CHANGELOG-DEV.md +git add CHANGELOG-DEV.md diff --git a/scripts/hooks/post-commit b/scripts/hooks/post-commit new file mode 100755 index 0000000..a72d353 --- /dev/null +++ b/scripts/hooks/post-commit @@ -0,0 +1,24 @@ +#!/bin/bash +# post-commit: ZIP bauen, pushen, Gitea-Release veröffentlichen (nur dev-Branch) + +branch=$(git symbolic-ref --short HEAD 2>/dev/null) +[[ "$branch" != "dev" ]] && exit 0 + +root=$(git rev-parse --show-toplevel) +cd "$root" + +# ZIP bauen +echo "[hook] Baue ZIP..." +bash scripts/build_kodi_zip.sh + +# Push +echo "[hook] Push origin dev..." +git push origin dev + +# Gitea Release +if [[ -n "$GITEA_TOKEN" ]]; then + echo "[hook] Veröffentliche Gitea-Release..." + bash scripts/publish_gitea_release.sh +else + echo "[hook] GITEA_TOKEN nicht gesetzt – Gitea-Release übersprungen" +fi diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 0000000..436c7e9 --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,27 @@ +#!/bin/bash +# pre-commit: Patch-Version in addon.xml automatisch hochzählen (nur dev-Branch) + +branch=$(git symbolic-ref --short HEAD 2>/dev/null) +[[ "$branch" != "dev" ]] && exit 0 + +root=$(git rev-parse --show-toplevel) +cd "$root" + +# Version aus addon.xml lesen +current=$(grep -oP 'version="\K[0-9]+\.[0-9]+\.[0-9]+[^"]*' addon/addon.xml | head -1) +if [[ -z "$current" ]]; then + echo "[hook] Fehler: Version nicht gefunden in addon/addon.xml" >&2 + exit 1 +fi + +# Patch-Nummer extrahieren und hochzählen +IFS='.' read -r major minor patch_full <<< "$current" +patch=$(echo "$patch_full" | grep -oP '^\d+') +suffix=$(echo "$patch_full" | grep -oP '[^0-9].*' || true) +new_version="${major}.${minor}.$((patch + 1))${suffix}" + +# addon.xml aktualisieren +sed -i "s/version=\"${current}\"/version=\"${new_version}\"/" addon/addon.xml +git add addon/addon.xml + +echo "[hook] Version: $current → $new_version" diff --git a/scripts/install_hooks.sh b/scripts/install_hooks.sh new file mode 100644 index 0000000..5dc30ce --- /dev/null +++ b/scripts/install_hooks.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Installiert Git Hooks für das Dev-Workflow als Symlinks + +root=$(git rev-parse --show-toplevel) +hooks_src="$root/scripts/hooks" +hooks_dst="$root/.git/hooks" + +for hook in pre-commit commit-msg post-commit; do + chmod +x "$hooks_src/$hook" + ln -sf "$hooks_src/$hook" "$hooks_dst/$hook" + echo "Installiert: $hook" +done + +echo "Alle Hooks aktiv." diff --git a/tests/test_moflix_plugin.py b/tests/test_moflix_plugin.py index 2c03ad6..71d254f 100644 --- a/tests/test_moflix_plugin.py +++ b/tests/test_moflix_plugin.py @@ -374,50 +374,55 @@ def test_stream_link_for_movie_cache_miss(monkeypatch): # --------------------------------------------------------------------------- -# Tests: _best_src_from_videos +# Tests: _hosters_from_videos # --------------------------------------------------------------------------- -def test_best_src_prefers_vidara_over_fallback(): +def test_hosters_skips_gupload(): plugin = MoflixPlugin() videos = [ - {"src": "https://moflix-stream.link/e/abc", "quality": "1080p"}, - {"src": "https://vidara.to/e/xyz789", "quality": "1080p"}, + {"src": "https://gupload.xyz/data/e/hash", "name": "GUpload"}, + {"src": "https://moflix-stream.link/e/abc", "name": "Mirror-HDCloud"}, ] - assert plugin._best_src_from_videos(videos) == "https://vidara.to/e/xyz789" + hosters = plugin._hosters_from_videos(videos) + assert "https://gupload.xyz/data/e/hash" not in hosters.values() + assert "https://moflix-stream.link/e/abc" in hosters.values() -def test_best_src_skips_gupload(): +def test_hosters_skips_youtube(): plugin = MoflixPlugin() videos = [ - {"src": "https://gupload.xyz/data/e/hash", "quality": "1080p"}, - {"src": "https://moflix-stream.link/e/abc", "quality": "1080p"}, + {"src": "https://youtube.com/watch?v=xyz", "name": "YouTube"}, + {"src": "https://vidara.to/e/real123", "name": "Vidara"}, ] - # gupload übersprungen, moflix-stream.link als Fallback - assert plugin._best_src_from_videos(videos) == "https://moflix-stream.link/e/abc" + hosters = plugin._hosters_from_videos(videos) + assert len(hosters) == 1 + assert "https://vidara.to/e/real123" in hosters.values() -def test_best_src_skips_youtube(): - plugin = MoflixPlugin() - videos = [ - {"src": "https://youtube.com/watch?v=xyz", "quality": None}, - {"src": "https://vidara.to/e/real123", "quality": "1080p"}, - ] - assert plugin._best_src_from_videos(videos) == "https://vidara.to/e/real123" - - -def test_best_src_all_skipped_returns_none(): +def test_hosters_all_skipped_returns_empty(): plugin = MoflixPlugin() videos = [ {"src": "https://gupload.xyz/data/e/hash"}, {"src": "https://youtube.com/watch?v=xyz"}, ] - assert plugin._best_src_from_videos(videos) is None + assert plugin._hosters_from_videos(videos) == {} -def test_best_src_empty_returns_none(): +def test_hosters_empty_returns_empty(): plugin = MoflixPlugin() - assert plugin._best_src_from_videos([]) is None - assert plugin._best_src_from_videos(None) is None # type: ignore[arg-type] + assert plugin._hosters_from_videos([]) == {} + + +def test_available_hosters_for_returns_names(): + plugin = MoflixPlugin() + videos = [ + {"src": "https://vidara.to/e/xyz", "name": "Vidara-720"}, + {"src": "https://moflix-stream.click/e/abc", "name": "Mirror-HDCloud"}, + ] + # Mock _videos_for um direkt zu testen + plugin._videos_for = lambda *a, **kw: videos # type: ignore[assignment] + names = plugin.available_hosters_for("Test", "Film", "Test") + assert len(names) == 2 # --------------------------------------------------------------------------- @@ -472,7 +477,7 @@ def test_channel_empty_response_returns_empty(monkeypatch): plugin = MoflixPlugin() monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: None) assert plugin.popular_series() == [] - assert plugin.latest_titles() == [] + assert plugin.new_titles() == [] def test_channel_malformed_response_returns_empty(monkeypatch): @@ -523,7 +528,7 @@ def test_capabilities(): plugin = MoflixPlugin() caps = plugin.capabilities() assert "popular_series" in caps - assert "latest_titles" in caps + assert "new_titles" in caps assert "genres" in caps assert "collections" in caps @@ -670,25 +675,26 @@ def test_resolve_stream_link_vidhide_fallback_on_failure(monkeypatch): # Tests: _best_src_from_videos – moflix-stream.click nicht mehr übersprungen # --------------------------------------------------------------------------- -def test_best_src_vidhide_not_skipped(): +def test_hosters_vidhide_not_skipped(): """moflix-stream.click ist nicht mehr in _VIDEO_SKIP_DOMAINS.""" plugin = MoflixPlugin() videos = [ - {"src": "https://moflix-stream.click/embed/abc123", "quality": "1080p"}, + {"src": "https://moflix-stream.click/embed/abc123", "name": "Mirror-VidHide"}, ] - result = plugin._best_src_from_videos(videos) - assert result == "https://moflix-stream.click/embed/abc123" + hosters = plugin._hosters_from_videos(videos) + assert "https://moflix-stream.click/embed/abc123" in hosters.values() -def test_best_src_vidara_preferred_over_vidhide(): - """vidara.to hat Vorrang vor moflix-stream.click.""" +def test_hosters_vidara_present(): + """vidara.to wird korrekt als Hoster erkannt.""" plugin = MoflixPlugin() videos = [ - {"src": "https://moflix-stream.click/embed/abc123", "quality": "1080p"}, - {"src": "https://vidara.to/e/xyz789", "quality": "1080p"}, + {"src": "https://moflix-stream.click/embed/abc123", "name": "Mirror-VidHide"}, + {"src": "https://vidara.to/e/xyz789", "name": "Vidara-720"}, ] - result = plugin._best_src_from_videos(videos) - assert result == "https://vidara.to/e/xyz789" + hosters = plugin._hosters_from_videos(videos) + assert len(hosters) == 2 + assert "https://vidara.to/e/xyz789" in hosters.values() def test_stream_link_for_movie_vidhide_only(monkeypatch):