Files
ViewIT/addon/plugins/moflix_plugin.py

756 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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