dev: bump to 0.1.72-dev – Autoplay-Setting, Moflix Hoster-Dialog, Update-Hinweis im Hauptmenue
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<addon id="plugin.video.viewit" name="ViewIt" version="0.1.72-dev" provider-name="ViewIt">
|
||||
<addon id="plugin.video.viewit" name="ViewIt" version="0.1.73-dev" provider-name="ViewIt">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="3.0.0" />
|
||||
<import addon="script.module.requests" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<settings>
|
||||
<category label="Quellen">
|
||||
<setting id="serienstream_base_url" type="text" label="SerienStream Basis-URL" default="https://s.to" />
|
||||
<setting id="serienstream_catalog_search" type="bool" label="SerienStream: Katalog-Suche (mehr Ergebnisse, langsamer)" default="true" />
|
||||
<setting id="aniworld_base_url" type="text" label="AniWorld Basis-URL" default="https://aniworld.to" />
|
||||
<setting id="topstream_base_url" type="text" label="TopStream Basis-URL" default="https://topstreamfilm.live" />
|
||||
<setting id="einschalten_base_url" type="text" label="Einschalten Basis-URL" default="https://einschalten.in" />
|
||||
@@ -16,6 +17,8 @@
|
||||
<setting id="einschalten_metadata_source" type="enum" label="Einschalten Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
|
||||
<setting id="filmpalast_metadata_source" type="enum" label="Filmpalast Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
|
||||
<setting id="doku_streams_metadata_source" type="enum" label="Doku-Streams Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
|
||||
<setting id="kkiste_metadata_source" type="enum" label="KKiste Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
|
||||
<setting id="moflix_metadata_source" type="enum" label="Moflix Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
|
||||
<setting id="tmdb_enabled" type="bool" label="TMDB aktivieren" default="true" />
|
||||
<setting id="tmdb_language" type="text" label="TMDB Sprache (z. B. de-DE)" default="de-DE" />
|
||||
<setting id="tmdb_show_plot" type="bool" label="TMDB Beschreibung anzeigen" default="true" />
|
||||
@@ -35,10 +38,16 @@
|
||||
<setting id="tmdb_log_responses" type="bool" label="TMDB API-Antworten loggen" default="false" />
|
||||
</category>
|
||||
|
||||
<category label="Wiedergabe">
|
||||
<setting id="autoplay_enabled" type="bool" label="Autoplay (bevorzugten Hoster automatisch waehlen)" default="false" />
|
||||
<setting id="preferred_hoster" type="text" label="Bevorzugter Hoster" default="voe" />
|
||||
</category>
|
||||
|
||||
<category label="Updates">
|
||||
<setting id="update_channel" type="enum" label="Update-Kanal" default="1" values="Main|Nightly|Custom|Dev" />
|
||||
<setting id="apply_update_channel" type="action" label="Update-Kanal jetzt anwenden" action="RunPlugin(plugin://plugin.video.viewit/?action=apply_update_channel)" option="close" />
|
||||
<setting id="auto_update_enabled" type="bool" label="Automatische Updates (beim Start pruefen)" default="false" />
|
||||
<setting id="auto_update_interval" type="enum" label="Update-Pruefintervall" default="1" values="1 Stunde|6 Stunden|24 Stunden" />
|
||||
<setting id="select_update_version" type="action" label="Version waehlen und installieren" action="RunPlugin(plugin://plugin.video.viewit/?action=select_update_version)" option="close" />
|
||||
<setting id="install_resolveurl" type="action" label="ResolveURL installieren/reparieren" action="RunPlugin(plugin://plugin.video.viewit/?action=install_resolveurl)" option="close" />
|
||||
<setting id="resolveurl_auto_install" type="bool" label="ResolveURL automatisch installieren (beim Start pruefen)" default="true" />
|
||||
|
||||
Reference in New Issue
Block a user