dev: bump to 0.1.71-dev – neue Plugins (Moflix, KKiste, HDFilme, Netzkino), SerienStream A-Z, VidHide-Fix

This commit is contained in:
2026-03-04 22:29:49 +01:00
parent ff30548811
commit 58da715723
7 changed files with 2460 additions and 3 deletions

View File

@@ -0,0 +1,755 @@
"""Moflix-Stream Plugin für ViewIT.
Nutzt die JSON-REST-API von moflix-stream.xyz.
Kein HTML-Parsing nötig alle Daten kommen als JSON.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, Callable, List, Optional
from urllib.parse import quote, quote_plus, urlparse
try: # pragma: no cover - optional dependency
import requests
except ImportError as exc: # pragma: no cover
requests = None
REQUESTS_AVAILABLE = False
REQUESTS_IMPORT_ERROR = exc
else:
REQUESTS_AVAILABLE = True
REQUESTS_IMPORT_ERROR = None
from plugin_interface import BasisPlugin
if TYPE_CHECKING: # pragma: no cover
from requests import Session as RequestsSession
else: # pragma: no cover
RequestsSession = Any
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
# ---------------------------------------------------------------------------
# Konstanten
# ---------------------------------------------------------------------------
ADDON_ID = "plugin.video.viewit"
BASE_URL = "https://moflix-stream.xyz"
DEFAULT_TIMEOUT = 20
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
"Connection": "keep-alive",
"Referer": BASE_URL + "/",
}
# Separate Header-Definition für VidHide-Requests (moflix-stream.click)
# Separater Browser-UA verhindert UA-basierte Blockierung durch VidHide
_VIDHIDE_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
"Connection": "keep-alive",
"Referer": BASE_URL + "/",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "cross-site",
}
# Hoster-Domains, die erfahrungsgemäß 403 liefern oder kein ResolveURL-Support haben
_VIDEO_SKIP_DOMAINS: frozenset[str] = frozenset({
"gupload.xyz",
"veev.to",
})
# Hoster-Domains, die direkt über eine eigene API auflösbar sind (bevorzugen)
_VIDEO_PREFER_DOMAINS: frozenset[str] = frozenset({
"vidara.to",
})
_URL_SEARCH = BASE_URL + "/api/v1/search/{q1}?query={q2}&limit=8"
_URL_CHANNEL = BASE_URL + "/api/v1/channel/{slug}?channelType=channel&restriction=&paginate=simple"
_URL_TITLE = (
BASE_URL + "/api/v1/titles/{id}"
"?load=images,genres,productionCountries,keywords,videos,primaryVideo,seasons,compactCredits"
)
_URL_EPISODES = BASE_URL + "/api/v1/titles/{id}/seasons/{s}/episodes?perPage=100&query=&page=1"
_URL_EPISODE = (
BASE_URL + "/api/v1/titles/{id}/seasons/{s}/episodes/{e}"
"?load=videos,compactCredits,primaryVideo"
)
# Genre-Slugs (hardcodiert, da keine Genre-API vorhanden)
GENRE_SLUGS: dict[str, str] = {
"Action": "action",
"Animation": "animation",
"Dokumentation": "dokumentation",
"Drama": "drama",
"Familie": "top-kids-liste",
"Fantasy": "fantasy",
"Horror": "horror",
"Komödie": "comedy",
"Krimi": "crime",
"Liebesfilm": "romance",
"Science-Fiction": "science-fiction",
"Thriller": "thriller",
}
# Collections (Slugs aus dem offiziellen xStream-Plugin)
COLLECTION_SLUGS: dict[str, str] = {
"American Pie Complete Collection": "the-american-pie-collection",
"Bud Spencer & Terence Hill": "bud-spencer-terence-hill-collection",
"DC Superhelden Collection": "the-dc-universum-collection",
"Mission: Impossible Collection": "the-mission-impossible-collection",
"Fast & Furious Collection": "fast-furious-movie-collection",
"Halloween Collection": "halloween-movie-collection",
"Herr der Ringe Collection": "der-herr-der-ringe-collection",
"James Bond Collection": "the-james-bond-collection",
"Jason Bourne Collection": "the-jason-bourne-collection",
"Jurassic Park Collection": "the-jurassic-park-collection",
"Kinder & Familienfilme": "top-kids-liste",
"Marvel Cinematic Universe": "the-marvel-cinematic-universe-collection",
"Olsenbande Collection": "the-olsenbande-collection",
"Planet der Affen Collection": "the-planet-der-affen-collection",
"Rocky Collection": "rocky-the-knockout-collection",
"Star Trek Kinofilm Collection": "the-star-trek-movies-collection",
"Star Wars Collection": "the-star-wars-collection",
"Stirb Langsam Collection": "stirb-langsam-collection",
"X-Men Collection": "x-men-collection",
}
# ---------------------------------------------------------------------------
# Hilfsfunktionen (Modul-Ebene)
# ---------------------------------------------------------------------------
def _extract_first_number(label: str) -> int | None:
"""Extrahiert erste Ganzzahl aus einem Label. 'Staffel 2' → 2."""
m = re.search(r"\d+", label or "")
return int(m.group()) if m else None
def _normalize_video_name(name: str, src: str) -> str:
"""Normalisiert den Hoster-Namen eines Video-Objekts.
'Mirror-HDCloud' → Domain aus src; 'VidCloud-720''VidCloud'
"""
name = (name or "").strip()
if name.lower().startswith("mirror"):
parsed = urlparse(src or "")
host = parsed.netloc or ""
return host.split(".")[0].capitalize() if host else name
return name.split("-")[0].strip() or name
def _safe_str(value: object) -> str:
"""Konvertiert einen Wert sicher zu String, None → ''."""
if value is None:
return ""
return str(value).strip()
def _unpack_packer(packed_js: str) -> str:
"""Entpackt Dean Edwards p.a.c.k.e.r. JavaScript.
Format:
eval(function(p,a,c,k,e,d){...}('code',base,count,'k1|k2|...'.split('|'),0,0))
Findet die gepackte Zeichenkette, die Basis und den Schlüssel-String,
konvertiert jeden Token (base-N → Index) und ersetzt ihn durch das
jeweilige Schlüsselwort.
"""
m = re.search(
r"'((?:[^'\\]|\\.){20,})'\s*,\s*(\d+)\s*,\s*\d+\s*,\s*"
r"'((?:[^'\\]|\\.)*)'\s*\.split\s*\(\s*'\|'\s*\)",
packed_js,
)
if not m:
return packed_js
packed = m.group(1).replace("\\'", "'").replace("\\\\", "\\")
base = int(m.group(2))
keys = m.group(3).split("|")
_digits = "0123456789abcdefghijklmnopqrstuvwxyz"
def _unbase(s: str) -> int:
result = 0
for ch in s:
if ch not in _digits:
raise ValueError(f"Not a base-{base} digit: {ch!r}")
result = result * base + _digits.index(ch)
return result
def _replace(m2: re.Match) -> str: # type: ignore[type-arg]
token = m2.group(0)
try:
idx = _unbase(token)
replacement = keys[idx] if idx < len(keys) else ""
return replacement if replacement else token
except (ValueError, IndexError):
return token
return re.sub(r"\b\w+\b", _replace, packed)
# ---------------------------------------------------------------------------
# Plugin-Klasse
# ---------------------------------------------------------------------------
class MoflixPlugin(BasisPlugin):
"""Moflix-Stream Integration für ViewIT.
Verwendet die offizielle JSON-REST-API kein HTML-Scraping.
"""
name = "Moflix"
def __init__(self) -> None:
# title (str) → vollständige API-URL /api/v1/titles/{id}
self._title_to_url: dict[str, str] = {}
# title → (plot, poster_url, fanart_url)
self._title_meta: dict[str, tuple[str, str, str]] = {}
# title → True wenn Serie, False wenn Film
self._is_series: dict[str, bool] = {}
# (title, season_nr) → Moflix-API-ID (ändert sich pro Staffel!)
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]] = {}
# ------------------------------------------------------------------
# Verfügbarkeit
# ------------------------------------------------------------------
@property
def is_available(self) -> bool:
return REQUESTS_AVAILABLE
@property
def unavailable_reason(self) -> str:
if REQUESTS_AVAILABLE:
return ""
return f"requests nicht verfügbar: {REQUESTS_IMPORT_ERROR}"
# ------------------------------------------------------------------
# HTTP
# ------------------------------------------------------------------
def _get_session(self) -> RequestsSession:
from http_session_pool import get_requests_session
return get_requests_session("moflix", headers=HEADERS)
def _get_json(self, url: str, headers: dict | None = None) -> dict | list | None:
"""GET-Request, gibt geparste JSON-Antwort zurück oder None bei Fehler."""
session = self._get_session()
response = None
try:
response = session.get(url, headers=headers or HEADERS, timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
return response.json()
except Exception:
return None
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _get_html(
self,
url: str,
headers: dict | None = None,
fresh_session: bool = False,
) -> str | None:
"""GET-Request, gibt den Response-Text (HTML) zurück oder None bei Fehler.
fresh_session=True: eigene requests.Session (keine gecachten Cookies/State).
"""
response = None
try:
if fresh_session:
import requests as _req
session = _req.Session()
else:
session = self._get_session()
req_headers = headers or {
**HEADERS,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
response = session.get(url, headers=req_headers, timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
return response.text
except Exception:
return None
finally:
if response is not None:
try:
response.close()
except Exception:
pass
# ------------------------------------------------------------------
# Interne Hilfsmethoden
# ------------------------------------------------------------------
def _cache_channel_entry(self, entry: dict) -> str:
"""Cached einen Kanal/Sucheintrag und gibt den Titel zurück (oder '' zum Überspringen)."""
title = _safe_str(entry.get("name"))
if not title:
return ""
api_id = _safe_str(entry.get("id"))
if not api_id:
return ""
self._title_to_url[title] = _URL_TITLE.format(id=api_id)
is_series = bool(entry.get("is_series", False))
self._is_series[title] = is_series
plot = _safe_str(entry.get("description"))
poster = _safe_str(entry.get("poster"))
fanart = _safe_str(entry.get("backdrop"))
self._title_meta[title] = (plot, poster, fanart)
return title
def _titles_from_channel(self, slug: str, page: int = 1) -> list[str]:
"""Lädt Titel eines Moflix-Channels (Kategorie/Genre/Collection)."""
url = _URL_CHANNEL.format(slug=slug)
if page > 1:
url = f"{url}&page={page}"
data = self._get_json(url)
if not isinstance(data, dict):
return []
entries = []
try:
entries = data["channel"]["content"]["data"]
except (KeyError, TypeError):
return []
titles: list[str] = []
for entry in (entries or []):
if not isinstance(entry, dict):
continue
t = self._cache_channel_entry(entry)
if t:
titles.append(t)
return titles
def _ensure_title_url(self, title: str) -> str:
"""Gibt die gecachte API-URL für einen Titel zurück, oder ''."""
return self._title_to_url.get(title, "")
def _resolve_title(self, title: str) -> None:
"""Cache-Miss-Fallback: Titel per Such-API nachschlagen und cachen.
Wird aufgerufen wenn der In-Memory-Cache leer ist (z.B. nach einem
neuen Kodi-Addon-Aufruf, der eine frische Plugin-Instanz erzeugt).
"""
q1 = quote(title)
q2 = quote_plus(title)
url = _URL_SEARCH.format(q1=q1, q2=q2)
data = self._get_json(url)
if not isinstance(data, dict):
return
for entry in (data.get("results") or []):
if not isinstance(entry, dict):
continue
if _safe_str(entry.get("name")) == title:
self._cache_channel_entry(entry)
return
# ------------------------------------------------------------------
# Pflicht-Methoden
# ------------------------------------------------------------------
async def search_titles(
self,
query: str,
progress_callback: ProgressCallback = None,
) -> List[str]:
query = (query or "").strip()
if not query or not REQUESTS_AVAILABLE:
return []
q1 = quote(query)
q2 = quote_plus(query)
url = _URL_SEARCH.format(q1=q1, q2=q2)
data = self._get_json(url)
if not isinstance(data, dict):
return []
results = data.get("results") or []
titles: list[str] = []
for entry in results:
if not isinstance(entry, dict):
continue
# Personen überspringen
if "person" in _safe_str(entry.get("model_type")):
continue
t = self._cache_channel_entry(entry)
if t:
titles.append(t)
return titles
def seasons_for(self, title: str) -> List[str]:
title = (title or "").strip()
if not title:
return []
# Film: direkt zum Stream
if self._is_series.get(title) is False:
return ["Film"]
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 []
seasons_raw = []
try:
seasons_raw = data["seasons"]["data"]
except (KeyError, TypeError):
pass
if not seasons_raw:
# Kein Staffel-Daten → Film-Fallback
return ["Film"]
# Nach Staffelnummer sortieren
seasons_raw = sorted(seasons_raw, key=lambda s: int(s.get("number", 0) or 0))
labels: list[str] = []
for season in seasons_raw:
if not isinstance(season, dict):
continue
nr = season.get("number")
api_id = _safe_str(season.get("title_id"))
if nr is None or not api_id:
continue
try:
season_nr = int(nr)
except (ValueError, TypeError):
continue
self._season_api_ids[(title, season_nr)] = api_id
labels.append(f"Staffel {season_nr}")
return labels
def episodes_for(self, title: str, season: str) -> List[str]:
title = (title or "").strip()
season = (season or "").strip()
if not title or not season:
return []
# Film: Episode = Titel selbst
if season == "Film":
return [title]
season_nr = _extract_first_number(season)
if season_nr is None:
return []
# Cache-Hit
cached = self._episode_labels.get((title, season_nr))
if cached is not None:
return cached
api_id = self._season_api_ids.get((title, season_nr), "")
if not api_id:
# Staffeln nachladen falls noch nicht gecacht
self.seasons_for(title)
api_id = self._season_api_ids.get((title, season_nr), "")
if not api_id:
return []
url = _URL_EPISODES.format(id=api_id, s=season_nr)
data = self._get_json(url)
if not isinstance(data, dict):
return []
episodes_raw = []
try:
episodes_raw = data["pagination"]["data"]
except (KeyError, TypeError):
pass
labels: list[str] = []
for ep in (episodes_raw or []):
if not isinstance(ep, dict):
continue
# Episoden ohne Video überspringen
if ep.get("primary_video") is None:
continue
ep_nr_raw = ep.get("episode_number")
ep_name = _safe_str(ep.get("name"))
try:
ep_nr = int(ep_nr_raw or 0)
except (ValueError, TypeError):
continue
if ep_nr <= 0:
continue
label = f"Episode {ep_nr}"
if ep_name:
label = f"{label} {ep_name}"
labels.append(label)
self._episode_labels[(title, season_nr)] = labels
return labels
# ------------------------------------------------------------------
# Stream
# ------------------------------------------------------------------
def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
title = (title or "").strip()
season = (season or "").strip()
if season == "Film":
return self._stream_link_for_movie(title)
season_nr = _extract_first_number(season)
episode_nr = _extract_first_number(episode)
if season_nr is None or episode_nr is None:
return None
# 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
# 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)
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 []):
if not isinstance(v, dict):
continue
src = _safe_str(v.get("src"))
if not src or "youtube" in src.lower():
continue
domain = urlparse(src).netloc.lstrip("www.")
if domain in _VIDEO_SKIP_DOMAINS:
continue
if domain in _VIDEO_PREFER_DOMAINS:
preferred.append(src)
else:
fallback.append(src)
candidates = preferred + fallback
return candidates[0] if candidates else None
def _resolve_vidara(self, filecode: str) -> Optional[str]:
"""Löst einen vidara.to-Filecode über die vidara-API auf → HLS-URL."""
api_url = f"https://vidara.to/api/stream?filecode={filecode}"
vidara_headers = {
**HEADERS,
"Referer": f"https://vidara.to/e/{filecode}",
"Origin": "https://vidara.to",
}
data = self._get_json(api_url, headers=vidara_headers)
if not isinstance(data, dict):
return None
return _safe_str(data.get("streaming_url")) or None
def _resolve_vidhide(self, embed_url: str) -> Optional[str]:
"""Löst einen VidHide-Embed-Link (moflix-stream.click) auf → HLS-URL.
Verwendet eine frische Session mit echtem Chrome-UA um UA-basierte
Blockierungen zu umgehen. Entpackt p.a.c.k.e.r.-JS und extrahiert
den HLS-Stream aus links.hls4/hls3/hls2.
"""
# Frische Session (NICHT die gecachte "moflix"-Session) mit VidHide-Headers
html = self._get_html(embed_url, headers=_VIDHIDE_HEADERS, fresh_session=True)
if not html or "eval(function(p,a,c,k,e" not in html:
return None
unpacked = _unpack_packer(html)
# Priorität: hls4 > hls3 > hls2
for hls_key in ("hls4", "hls3", "hls2"):
m = re.search(rf'"{hls_key}"\s*:\s*"(https://[^"]+)"', unpacked)
if m:
url = m.group(1)
if url:
# Kodi braucht Referer + UA als Header-Suffix damit der CDN die HLS-URL akzeptiert
from urllib.parse import urlencode
headers = urlencode({
"Referer": embed_url,
"User-Agent": _VIDHIDE_HEADERS["User-Agent"],
})
return f"{url}|{headers}"
return None
def resolve_stream_link(self, link: str) -> Optional[str]:
link = (link or "").strip()
if not link:
return None
# vidara.to: direkt über eigene API auflösen
vidara_m = re.search(r'vidara\.to/e/([A-Za-z0-9_-]+)', link)
if vidara_m:
resolved = self._resolve_vidara(vidara_m.group(1))
if resolved:
return resolved
# VidHide (moflix-stream.click): zuerst ResolveURL probieren (FileLions-Modul
# nutzt Kodis libcurl mit anderem TLS-Fingerprint), dann eigenen Resolver
if "moflix-stream.click" in link:
try:
from plugin_helpers import resolve_via_resolveurl
resolved = resolve_via_resolveurl(link, fallback_to_link=False)
if resolved:
return resolved
except Exception:
pass
# Fallback: eigener p.a.c.k.e.r. Resolver
resolved = self._resolve_vidhide(link)
if resolved:
return resolved
return None
# Fallback: ResolveURL (ohne Link-Fallback lieber None als unauflösbaren Link)
try:
from plugin_helpers import resolve_via_resolveurl
return resolve_via_resolveurl(link, fallback_to_link=False)
except Exception:
return None
# ------------------------------------------------------------------
# Metadaten
# ------------------------------------------------------------------
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] = {}
# Cache-Hit
cached = self._title_meta.get(title)
if cached:
plot, poster, fanart = cached
if plot:
info["plot"] = plot
if poster:
art["thumb"] = poster
art["poster"] = poster
if fanart:
art["fanart"] = fanart
art["landscape"] = fanart
if "plot" in info or art:
return info, art, None
# API-Abruf
url = self._ensure_title_url(title)
if not url:
return info, art, None
data = self._get_json(url)
if not isinstance(data, dict):
return info, art, None
title_obj = data.get("title") or {}
plot = _safe_str(title_obj.get("description"))
poster = _safe_str(title_obj.get("poster"))
fanart = _safe_str(title_obj.get("backdrop"))
rating_raw = title_obj.get("rating")
year_raw = _safe_str(title_obj.get("release_date"))
if plot:
info["plot"] = plot
if rating_raw is not None:
try:
info["rating"] = str(float(rating_raw))
except (ValueError, TypeError):
pass
if year_raw and len(year_raw) >= 4:
info["year"] = year_raw[:4]
if poster:
art["thumb"] = poster
art["poster"] = poster
if fanart:
art["fanart"] = fanart
art["landscape"] = fanart
# Cachen
self._title_meta[title] = (plot, poster, fanart)
return info, art, None
# ------------------------------------------------------------------
# Browsing-Features
# ------------------------------------------------------------------
def popular_series(self) -> List[str]:
return self._titles_from_channel("series")
def latest_titles(self, page: int = 1) -> List[str]:
return self._titles_from_channel("now-playing", page=page)
def genres(self) -> List[str]:
return sorted(GENRE_SLUGS.keys())
def titles_for_genre(self, genre: str) -> List[str]:
return self.titles_for_genre_page(genre, 1)
def titles_for_genre_page(self, genre: str, page: int = 1) -> List[str]:
slug = GENRE_SLUGS.get(genre, "")
if not slug:
return []
return self._titles_from_channel(slug, page=page)
def collections(self) -> List[str]:
return sorted(COLLECTION_SLUGS.keys())
def titles_for_collection(self, collection: str, page: int = 1) -> List[str]:
slug = COLLECTION_SLUGS.get(collection, "")
if not slug:
return []
return self._titles_from_channel(slug, page=page)
def capabilities(self) -> set[str]:
return {"popular_series", "latest_titles", "collections", "genres"}