dev: bump to 0.1.71-dev – neue Plugins (Moflix, KKiste, HDFilme, Netzkino), SerienStream A-Z, VidHide-Fix
This commit is contained in:
755
addon/plugins/moflix_plugin.py
Normal file
755
addon/plugins/moflix_plugin.py
Normal 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"}
|
||||
Reference in New Issue
Block a user