dev: bump to 0.1.72-dev – Autoplay-Setting, Moflix Hoster-Dialog, Update-Hinweis im Hauptmenue

This commit is contained in:
2026-03-06 21:05:53 +01:00
parent 957a5a1aea
commit 6e7b4c3d39
13 changed files with 473 additions and 205 deletions

View File

@@ -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 `<li id="{episode_id}">` 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"}

View File

@@ -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"}

View File

@@ -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"}

View File

@@ -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)