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'?>
|
<?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>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0" />
|
<import addon="xbmc.python" version="3.0.0" />
|
||||||
<import addon="script.module.requests" />
|
<import addon="script.module.requests" />
|
||||||
|
|||||||
@@ -701,18 +701,11 @@ def show_version_selector() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dialog = xbmcgui.Dialog()
|
action = xbmcgui.Dialog().select(
|
||||||
try:
|
f"Version {version} installieren?",
|
||||||
confirmed = dialog.yesno(
|
["Update installieren", "Abbrechen"],
|
||||||
"Version installieren",
|
)
|
||||||
f"Installiert: {installed}",
|
if action != 0:
|
||||||
f"Ausgewaehlt: {version}",
|
|
||||||
yeslabel="Installieren",
|
|
||||||
nolabel="Abbrechen",
|
|
||||||
)
|
|
||||||
except TypeError:
|
|
||||||
confirmed = dialog.yesno("Version installieren", f"Installiert: {installed}", f"Ausgewaehlt: {version}")
|
|
||||||
if not confirmed:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
show_notification("Updates", f"Installation gestartet: {version}", milliseconds=2500)
|
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_MAIN = 0
|
||||||
UPDATE_CHANNEL_NIGHTLY = 1
|
UPDATE_CHANNEL_NIGHTLY = 1
|
||||||
UPDATE_CHANNEL_CUSTOM = 2
|
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_HTTP_TIMEOUT_SEC = 8
|
||||||
UPDATE_ADDON_ID = "plugin.video.viewit"
|
UPDATE_ADDON_ID = "plugin.video.viewit"
|
||||||
RESOLVEURL_ADDON_ID = "script.module.resolveurl"
|
RESOLVEURL_ADDON_ID = "script.module.resolveurl"
|
||||||
@@ -1644,6 +1644,17 @@ def _sync_update_version_settings() -> None:
|
|||||||
def _show_root_menu() -> None:
|
def _show_root_menu() -> None:
|
||||||
handle = _get_handle()
|
handle = _get_handle()
|
||||||
_log("Root-Menue wird angezeigt.")
|
_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")
|
_add_directory_item(handle, "Suche in allen Quellen", "search")
|
||||||
|
|
||||||
plugins = _discover_plugins()
|
plugins = _discover_plugins()
|
||||||
@@ -3762,18 +3773,11 @@ def _show_version_selector() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
dialog = xbmcgui.Dialog()
|
action = xbmcgui.Dialog().select(
|
||||||
try:
|
f"Version {version} installieren?",
|
||||||
confirmed = dialog.yesno(
|
["Update installieren", "Abbrechen"],
|
||||||
"Version installieren",
|
)
|
||||||
f"Installiert: {installed}",
|
if action != 0:
|
||||||
f"Ausgewaehlt: {version}",
|
|
||||||
yeslabel="Installieren",
|
|
||||||
nolabel="Abbrechen",
|
|
||||||
)
|
|
||||||
except TypeError:
|
|
||||||
confirmed = dialog.yesno("Version installieren", f"Installiert: {installed}", f"Ausgewaehlt: {version}")
|
|
||||||
if not confirmed:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
xbmcgui.Dialog().notification("Updates", f"Installation gestartet: {version}", xbmcgui.NOTIFICATION_INFO, 2500)
|
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
|
return
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
last = _get_setting_int("auto_update_last_ts", default=0)
|
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
|
return
|
||||||
_set_setting_string("auto_update_last_ts", str(now))
|
_set_setting_string("auto_update_last_ts", str(now))
|
||||||
_run_update_check(silent=True)
|
_run_update_check(silent=True)
|
||||||
@@ -4038,12 +4044,23 @@ def _play_episode(
|
|||||||
|
|
||||||
selected_hoster: str | None = None
|
selected_hoster: str | None = None
|
||||||
forced_hoster = (forced_hoster or "").strip()
|
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 available_hosters:
|
||||||
if forced_hoster:
|
if forced_hoster:
|
||||||
for hoster in available_hosters:
|
for hoster in available_hosters:
|
||||||
if hoster.casefold() == forced_hoster.casefold():
|
if hoster.casefold() == forced_hoster.casefold():
|
||||||
selected_hoster = hoster
|
selected_hoster = hoster
|
||||||
break
|
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:
|
if selected_hoster is None and len(available_hosters) == 1:
|
||||||
selected_hoster = available_hosters[0]
|
selected_hoster = available_hosters[0]
|
||||||
elif selected_hoster is None:
|
elif selected_hoster is None:
|
||||||
@@ -4180,10 +4197,21 @@ def _play_episode_url(
|
|||||||
_log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
_log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
|
||||||
|
|
||||||
selected_hoster: str | None = None
|
selected_hoster: str | None = None
|
||||||
|
autoplay = _get_setting_bool("autoplay_enabled", default=False)
|
||||||
|
preferred = _get_setting_string("preferred_hoster").strip()
|
||||||
if available_hosters:
|
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]
|
selected_hoster = available_hosters[0]
|
||||||
else:
|
elif selected_hoster is None:
|
||||||
selected_index = xbmcgui.Dialog().select("Hoster waehlen", available_hosters)
|
selected_index = xbmcgui.Dialog().select("Hoster waehlen", available_hosters)
|
||||||
if selected_index is None or selected_index < 0:
|
if selected_index is None or selected_index < 0:
|
||||||
_log("Play abgebrochen (kein Hoster gewählt).", xbmc.LOGDEBUG)
|
_log("Play abgebrochen (kein Hoster gewählt).", xbmc.LOGDEBUG)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ HEADERS = {
|
|||||||
"Connection": "keep-alive",
|
"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_NEW = BASE_URL + "/kinofilme-online/"
|
||||||
_URL_SERIES = BASE_URL + "/serienstream-deutsch/"
|
_URL_SERIES = BASE_URL + "/serienstream-deutsch/"
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ GENRE_SLUGS: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Hoster die übersprungen werden (kein Stream / nur Trailer)
|
# 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]]
|
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
|
||||||
|
|
||||||
@@ -122,6 +122,7 @@ class HdfilmePlugin(BasisPlugin):
|
|||||||
self._is_series: dict[str, bool] = {}
|
self._is_series: dict[str, bool] = {}
|
||||||
self._title_meta: dict[str, tuple[str, str]] = {} # title → (plot, poster)
|
self._title_meta: dict[str, tuple[str, str]] = {} # title → (plot, poster)
|
||||||
self._episode_cache: dict[str, list[str]] = {} # detail_url → episode labels
|
self._episode_cache: dict[str, list[str]] = {} # detail_url → episode labels
|
||||||
|
self._preferred_hosters: list[str] = []
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Verfügbarkeit
|
# Verfügbarkeit
|
||||||
@@ -182,40 +183,64 @@ class HdfilmePlugin(BasisPlugin):
|
|||||||
titles.append(title)
|
titles.append(title)
|
||||||
return titles
|
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:
|
def _get_detail_soup(self, title: str) -> Any:
|
||||||
"""Lädt die Detailseite eines Titels."""
|
"""Lädt die Detailseite eines Titels."""
|
||||||
url = self._title_to_url.get(title, "")
|
url = self._ensure_detail_url(title)
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
return _get_soup(url)
|
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.
|
"""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.
|
episode_id: wenn gesetzt, nur Links aus dem `<li id="{episode_id}">` Block.
|
||||||
"""
|
"""
|
||||||
if soup is None:
|
if soup is None:
|
||||||
return []
|
return {}
|
||||||
links: list[str] = []
|
hosters: dict[str, str] = {}
|
||||||
|
|
||||||
if episode_id:
|
if episode_id:
|
||||||
# Serien-Episode: Links aus dem spezifischen Episode-Container
|
|
||||||
container = soup.select_one(f"li#{episode_id}")
|
container = soup.select_one(f"li#{episode_id}")
|
||||||
if container is None:
|
if container is None:
|
||||||
return []
|
return {}
|
||||||
candidates = container.select("a[data-link]")
|
candidates = container.select("a[data-link]")
|
||||||
else:
|
else:
|
||||||
# Film: Links aus .mirrors
|
|
||||||
candidates = soup.select(".mirrors [data-link]")
|
candidates = soup.select(".mirrors [data-link]")
|
||||||
|
|
||||||
|
seen_names: set[str] = set()
|
||||||
for el in candidates:
|
for el in candidates:
|
||||||
href = _absolute_url((el.get("data-link") or "").strip())
|
href = _absolute_url((el.get("data-link") or "").strip())
|
||||||
if not href:
|
if not href:
|
||||||
continue
|
continue
|
||||||
if any(kw in href for kw in _SKIP_LINK_KEYWORDS):
|
if any(kw in href for kw in _SKIP_LINK_KEYWORDS):
|
||||||
continue
|
continue
|
||||||
links.append(href)
|
name = el.get_text(strip=True) or "Hoster"
|
||||||
return links
|
# 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:
|
def _staffel_nr(self, season: str) -> int:
|
||||||
"""Extrahiert die Staffelnummer aus einem Label wie 'Staffel 2'."""
|
"""Extrahiert die Staffelnummer aus einem Label wie 'Staffel 2'."""
|
||||||
@@ -270,7 +295,7 @@ class HdfilmePlugin(BasisPlugin):
|
|||||||
if season == "Film":
|
if season == "Film":
|
||||||
return [title]
|
return [title]
|
||||||
|
|
||||||
detail_url = self._title_to_url.get(title, "")
|
detail_url = self._ensure_detail_url(title)
|
||||||
cached = self._episode_cache.get(detail_url)
|
cached = self._episode_cache.get(detail_url)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
@@ -304,27 +329,40 @@ class HdfilmePlugin(BasisPlugin):
|
|||||||
self._episode_cache[detail_url] = result
|
self._episode_cache[detail_url] = result
|
||||||
return 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]:
|
def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
|
||||||
title = (title or "").strip()
|
title = (title or "").strip()
|
||||||
season = (season or "").strip()
|
season = (season or "").strip()
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
|
hosters = self._hosters_for(title, season, episode)
|
||||||
soup = self._get_detail_soup(title)
|
if not hosters:
|
||||||
if soup is None:
|
|
||||||
return None
|
return None
|
||||||
|
# Bevorzugten Hoster nutzen falls gesetzt
|
||||||
if season == "Film" or not self._is_series.get(title, False):
|
for preferred in self._preferred_hosters:
|
||||||
# Film: .mirrors [data-link]
|
key = preferred.casefold()
|
||||||
links = self._extract_hoster_links(soup)
|
for name, url in hosters.items():
|
||||||
else:
|
if key in name.casefold() or key in url.casefold():
|
||||||
# Serie: Episode-Container
|
return url
|
||||||
staffel_nr = self._staffel_nr(season)
|
# Fallback: erster Hoster
|
||||||
ep_idx = self._ep_index(episode)
|
return next(iter(hosters.values()))
|
||||||
episode_id = f"serie-{staffel_nr}_{ep_idx}"
|
|
||||||
links = self._extract_hoster_links(soup, episode_id)
|
|
||||||
|
|
||||||
return links[0] if links else None
|
|
||||||
|
|
||||||
def resolve_stream_link(self, link: str) -> Optional[str]:
|
def resolve_stream_link(self, link: str) -> Optional[str]:
|
||||||
link = (link or "").strip()
|
link = (link or "").strip()
|
||||||
@@ -390,7 +428,12 @@ class HdfilmePlugin(BasisPlugin):
|
|||||||
# Browsing
|
# 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:
|
if not REQUESTS_AVAILABLE:
|
||||||
return []
|
return []
|
||||||
page = max(1, int(page or 1))
|
page = max(1, int(page or 1))
|
||||||
@@ -417,4 +460,4 @@ class HdfilmePlugin(BasisPlugin):
|
|||||||
return self._parse_entries(_get_soup(url))
|
return self._parse_entries(_get_soup(url))
|
||||||
|
|
||||||
def capabilities(self) -> set[str]:
|
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] = {}
|
self._is_series: dict[str, bool] = {}
|
||||||
# title → Staffelnummer (aus "Staffel N" extrahiert)
|
# title → Staffelnummer (aus "Staffel N" extrahiert)
|
||||||
self._season_nr: dict[str, int] = {}
|
self._season_nr: dict[str, int] = {}
|
||||||
|
# bevorzugte Hoster für Hoster-Dialog
|
||||||
|
self._preferred_hosters: list[str] = []
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Verfügbarkeit
|
# Verfügbarkeit
|
||||||
@@ -165,6 +167,24 @@ class KKistePlugin(BasisPlugin):
|
|||||||
self._title_meta[title] = (plot, poster, fanart)
|
self._title_meta[title] = (plot, poster, fanart)
|
||||||
return title
|
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]:
|
def _browse(self, content_type: str, order: str = "Trending") -> List[str]:
|
||||||
url = _URL_BROWSE.format(lang=_LANG, type=content_type, order=order, page=1)
|
url = _URL_BROWSE.format(lang=_LANG, type=content_type, order=order, page=1)
|
||||||
data = self._get_json(url)
|
data = self._get_json(url)
|
||||||
@@ -175,6 +195,55 @@ class KKistePlugin(BasisPlugin):
|
|||||||
if isinstance(movie, dict) and (t := self._cache_entry(movie))
|
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
|
# Pflicht-Methoden
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -211,10 +280,14 @@ class KKistePlugin(BasisPlugin):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
is_series = self._is_series.get(title)
|
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:
|
if is_series:
|
||||||
season_nr = self._season_nr.get(title, 1)
|
season_nr = self._season_nr.get(title, 1)
|
||||||
return [f"Staffel {season_nr}"]
|
return [f"Staffel {season_nr}"]
|
||||||
# Film (oder unbekannt → Film-Fallback)
|
|
||||||
return ["Film"]
|
return ["Film"]
|
||||||
|
|
||||||
def episodes_for(self, title: str, season: str) -> List[str]:
|
def episodes_for(self, title: str, season: str) -> List[str]:
|
||||||
@@ -222,12 +295,11 @@ class KKistePlugin(BasisPlugin):
|
|||||||
if not title:
|
if not title:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Film
|
|
||||||
if season == "Film":
|
if season == "Film":
|
||||||
return [title]
|
return [title]
|
||||||
|
|
||||||
# Serie: Episodenliste aus /data/watch/ laden
|
# 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:
|
if not watch_url:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -247,7 +319,6 @@ class KKistePlugin(BasisPlugin):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not episode_nrs:
|
if not episode_nrs:
|
||||||
# Keine Episoden-Nummern → als Film behandeln
|
|
||||||
return [title]
|
return [title]
|
||||||
|
|
||||||
return [f"Episode {nr}" for nr in sorted(episode_nrs)]
|
return [f"Episode {nr}" for nr in sorted(episode_nrs)]
|
||||||
@@ -256,48 +327,25 @@ class KKistePlugin(BasisPlugin):
|
|||||||
# Stream
|
# 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]:
|
def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
|
||||||
title = (title or "").strip()
|
title = (title or "").strip()
|
||||||
watch_url = self._title_to_watch_url.get(title, "")
|
hosters = self._hosters_for(title, season, episode)
|
||||||
if not watch_url:
|
if not hosters:
|
||||||
return None
|
return None
|
||||||
|
# Bevorzugten Hoster nutzen falls gesetzt
|
||||||
data = self._get_json(watch_url)
|
for preferred in self._preferred_hosters:
|
||||||
if not isinstance(data, dict):
|
key = preferred.casefold()
|
||||||
return None
|
for name, url in hosters.items():
|
||||||
|
if key in name.casefold() or key in url.casefold():
|
||||||
streams = data.get("streams") or []
|
return url
|
||||||
|
# Fallback: erster Hoster
|
||||||
if season == "Film":
|
return next(iter(hosters.values()))
|
||||||
# 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
|
|
||||||
|
|
||||||
def resolve_stream_link(self, link: str) -> Optional[str]:
|
def resolve_stream_link(self, link: str) -> Optional[str]:
|
||||||
link = (link or "").strip()
|
link = (link or "").strip()
|
||||||
@@ -341,12 +389,23 @@ class KKistePlugin(BasisPlugin):
|
|||||||
# Browsing
|
# 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]:
|
def popular_series(self) -> List[str]:
|
||||||
return self._browse("tvseries", "views")
|
return self._browse("tvseries", "views")
|
||||||
|
|
||||||
def latest_titles(self, page: int = 1) -> List[str]:
|
|
||||||
return self._browse("movies", "new")
|
|
||||||
|
|
||||||
def genres(self) -> List[str]:
|
def genres(self) -> List[str]:
|
||||||
return sorted(GENRE_SLUGS.keys())
|
return sorted(GENRE_SLUGS.keys())
|
||||||
|
|
||||||
@@ -364,4 +423,4 @@ class KKistePlugin(BasisPlugin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def capabilities(self) -> set[str]:
|
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] = {}
|
self._season_api_ids: dict[tuple[str, int], str] = {}
|
||||||
# (title, season_nr) → Liste der Episode-Labels
|
# (title, season_nr) → Liste der Episode-Labels
|
||||||
self._episode_labels: dict[tuple[str, int], list[str]] = {}
|
self._episode_labels: dict[tuple[str, int], list[str]] = {}
|
||||||
|
# bevorzugte Hoster für Hoster-Dialog
|
||||||
|
self._preferred_hosters: list[str] = []
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Verfügbarkeit
|
# Verfügbarkeit
|
||||||
@@ -510,57 +512,46 @@ class MoflixPlugin(BasisPlugin):
|
|||||||
# Stream
|
# 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()
|
title = (title or "").strip()
|
||||||
season = (season or "").strip()
|
season = (season or "").strip()
|
||||||
|
|
||||||
if season == "Film":
|
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)
|
season_nr = _extract_first_number(season)
|
||||||
episode_nr = _extract_first_number(episode)
|
episode_nr = _extract_first_number(episode)
|
||||||
if season_nr is None or episode_nr is None:
|
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), "")
|
api_id = self._season_api_ids.get((title, season_nr), "")
|
||||||
if not api_id:
|
if not api_id:
|
||||||
self.seasons_for(title)
|
self.seasons_for(title)
|
||||||
api_id = self._season_api_ids.get((title, season_nr), "")
|
api_id = self._season_api_ids.get((title, season_nr), "")
|
||||||
if not api_id:
|
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)
|
url = _URL_EPISODE.format(id=api_id, s=season_nr, e=episode_nr)
|
||||||
data = self._get_json(url)
|
data = self._get_json(url)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return None
|
return []
|
||||||
videos = (data.get("episode") or {}).get("videos") or []
|
return (data.get("episode") or {}).get("videos") or []
|
||||||
return self._best_src_from_videos(videos)
|
|
||||||
|
|
||||||
def _stream_link_for_movie(self, title: str) -> Optional[str]:
|
def _hosters_from_videos(self, videos: list) -> dict[str, str]:
|
||||||
"""Wählt den besten src-Link eines Films aus den API-Videos."""
|
"""Konvertiert videos[] zu {Hoster-Name → src-URL}, mit Skip/Prefer-Logik."""
|
||||||
url = self._ensure_title_url(title)
|
hosters: dict[str, str] = {}
|
||||||
if not url:
|
seen: set[str] = set()
|
||||||
self._resolve_title(title)
|
for v in videos:
|
||||||
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):
|
if not isinstance(v, dict):
|
||||||
continue
|
continue
|
||||||
src = _safe_str(v.get("src"))
|
src = _safe_str(v.get("src"))
|
||||||
@@ -569,12 +560,44 @@ class MoflixPlugin(BasisPlugin):
|
|||||||
domain = urlparse(src).netloc.lstrip("www.")
|
domain = urlparse(src).netloc.lstrip("www.")
|
||||||
if domain in _VIDEO_SKIP_DOMAINS:
|
if domain in _VIDEO_SKIP_DOMAINS:
|
||||||
continue
|
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:
|
if domain in _VIDEO_PREFER_DOMAINS:
|
||||||
preferred.append(src)
|
return url
|
||||||
else:
|
return next(iter(hosters.values()))
|
||||||
fallback.append(src)
|
|
||||||
candidates = preferred + fallback
|
|
||||||
return candidates[0] if candidates else None
|
|
||||||
|
|
||||||
def _resolve_vidara(self, filecode: str) -> Optional[str]:
|
def _resolve_vidara(self, filecode: str) -> Optional[str]:
|
||||||
"""Löst einen vidara.to-Filecode über die vidara-API auf → HLS-URL."""
|
"""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]:
|
def popular_series(self) -> List[str]:
|
||||||
return self._titles_from_channel("series")
|
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)
|
return self._titles_from_channel("now-playing", page=page)
|
||||||
|
|
||||||
def genres(self) -> List[str]:
|
def genres(self) -> List[str]:
|
||||||
@@ -752,4 +778,4 @@ class MoflixPlugin(BasisPlugin):
|
|||||||
return self._titles_from_channel(slug, page=page)
|
return self._titles_from_channel(slug, page=page)
|
||||||
|
|
||||||
def capabilities(self) -> set[str]:
|
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_BASE_URL = "serienstream_base_url"
|
||||||
|
SETTING_CATALOG_SEARCH = "serienstream_catalog_search"
|
||||||
DEFAULT_BASE_URL = "https://s.to"
|
DEFAULT_BASE_URL = "https://s.to"
|
||||||
DEFAULT_PREFERRED_HOSTERS = ["voe"]
|
DEFAULT_PREFERRED_HOSTERS = ["voe"]
|
||||||
DEFAULT_TIMEOUT = 20
|
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]:
|
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()
|
_ensure_requests()
|
||||||
if not _normalize_search_text(query):
|
if not _normalize_search_text(query):
|
||||||
return []
|
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)
|
use_catalog = _get_setting_bool(SETTING_CATALOG_SEARCH, default=True)
|
||||||
catalog_url = f"{_get_base_url()}/serien?by=genre"
|
|
||||||
items: list[SeriesResult] = []
|
if use_catalog:
|
||||||
try:
|
_emit_progress(progress_callback, "Pruefe Such-Cache", 15)
|
||||||
# Bevorzugt den Soup-Helper, damit Tests HTML einfache injizieren koennen.
|
cached = _load_catalog_index_from_cache()
|
||||||
soup = _get_soup_simple(catalog_url)
|
if cached is not None:
|
||||||
items = _catalog_index_from_soup(soup)
|
matched_from_cache = [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)]
|
||||||
except Exception:
|
_emit_progress(progress_callback, f"Cache-Treffer: {len(cached)}", 35)
|
||||||
body = _get_html_simple(catalog_url)
|
if matched_from_cache:
|
||||||
items = _extract_catalog_index_from_html(body, progress_callback=progress_callback)
|
return matched_from_cache
|
||||||
if not items:
|
|
||||||
_emit_progress(progress_callback, "Fallback-Parser", 58)
|
_emit_progress(progress_callback, "Lade Katalogseite", 42)
|
||||||
soup = BeautifulSoup(body, "html.parser")
|
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)
|
items = _catalog_index_from_soup(soup)
|
||||||
if items:
|
except Exception:
|
||||||
_store_catalog_index_in_cache(items)
|
body = _get_html_simple(catalog_url)
|
||||||
_emit_progress(progress_callback, f"Filtere Treffer ({len(items)})", 70)
|
items = _extract_catalog_index_from_html(body, progress_callback=progress_callback)
|
||||||
return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)]
|
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)
|
_emit_progress(progress_callback, "Server-Suche", 85)
|
||||||
server_results = _search_series_server(query)
|
server_results = _search_series_server(query)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<settings>
|
<settings>
|
||||||
<category label="Quellen">
|
<category label="Quellen">
|
||||||
<setting id="serienstream_base_url" type="text" label="SerienStream Basis-URL" default="https://s.to" />
|
<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="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="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" />
|
<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="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="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="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_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_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" />
|
<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" />
|
<setting id="tmdb_log_responses" type="bool" label="TMDB API-Antworten loggen" default="false" />
|
||||||
</category>
|
</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">
|
<category label="Updates">
|
||||||
<setting id="update_channel" type="enum" label="Update-Kanal" default="1" values="Main|Nightly|Custom|Dev" />
|
<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="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_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="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="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" />
|
<setting id="resolveurl_auto_install" type="bool" label="ResolveURL automatisch installieren (beim Start pruefen)" default="true" />
|
||||||
|
|||||||
28
scripts/hooks/commit-msg
Executable file
28
scripts/hooks/commit-msg
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# commit-msg: Commit-Message als Changelog-Eintrag in CHANGELOG-DEV.md prependen (nur dev-Branch)
|
||||||
|
|
||||||
|
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
|
||||||
|
[[ "$branch" != "dev" ]] && exit 0
|
||||||
|
|
||||||
|
root=$(git rev-parse --show-toplevel)
|
||||||
|
cd "$root"
|
||||||
|
|
||||||
|
msg=$(cat "$1")
|
||||||
|
version=$(grep -oP 'version="\K[0-9]+\.[0-9]+\.[0-9]+[^"]*' addon/addon.xml | head -1)
|
||||||
|
today=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
# Changelog-Eintrag aufbauen
|
||||||
|
# Jede nicht-leere Zeile der Commit-Message wird ein "- ..." Eintrag
|
||||||
|
{
|
||||||
|
echo "## ${version} - ${today}"
|
||||||
|
echo ""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
echo "- ${line}"
|
||||||
|
done <<< "$msg"
|
||||||
|
echo ""
|
||||||
|
cat CHANGELOG-DEV.md
|
||||||
|
} > /tmp/changelog_new.md
|
||||||
|
|
||||||
|
mv /tmp/changelog_new.md CHANGELOG-DEV.md
|
||||||
|
git add CHANGELOG-DEV.md
|
||||||
24
scripts/hooks/post-commit
Executable file
24
scripts/hooks/post-commit
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# post-commit: ZIP bauen, pushen, Gitea-Release veröffentlichen (nur dev-Branch)
|
||||||
|
|
||||||
|
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
|
||||||
|
[[ "$branch" != "dev" ]] && exit 0
|
||||||
|
|
||||||
|
root=$(git rev-parse --show-toplevel)
|
||||||
|
cd "$root"
|
||||||
|
|
||||||
|
# ZIP bauen
|
||||||
|
echo "[hook] Baue ZIP..."
|
||||||
|
bash scripts/build_kodi_zip.sh
|
||||||
|
|
||||||
|
# Push
|
||||||
|
echo "[hook] Push origin dev..."
|
||||||
|
git push origin dev
|
||||||
|
|
||||||
|
# Gitea Release
|
||||||
|
if [[ -n "$GITEA_TOKEN" ]]; then
|
||||||
|
echo "[hook] Veröffentliche Gitea-Release..."
|
||||||
|
bash scripts/publish_gitea_release.sh
|
||||||
|
else
|
||||||
|
echo "[hook] GITEA_TOKEN nicht gesetzt – Gitea-Release übersprungen"
|
||||||
|
fi
|
||||||
27
scripts/hooks/pre-commit
Executable file
27
scripts/hooks/pre-commit
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# pre-commit: Patch-Version in addon.xml automatisch hochzählen (nur dev-Branch)
|
||||||
|
|
||||||
|
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
|
||||||
|
[[ "$branch" != "dev" ]] && exit 0
|
||||||
|
|
||||||
|
root=$(git rev-parse --show-toplevel)
|
||||||
|
cd "$root"
|
||||||
|
|
||||||
|
# Version aus addon.xml lesen
|
||||||
|
current=$(grep -oP 'version="\K[0-9]+\.[0-9]+\.[0-9]+[^"]*' addon/addon.xml | head -1)
|
||||||
|
if [[ -z "$current" ]]; then
|
||||||
|
echo "[hook] Fehler: Version nicht gefunden in addon/addon.xml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Patch-Nummer extrahieren und hochzählen
|
||||||
|
IFS='.' read -r major minor patch_full <<< "$current"
|
||||||
|
patch=$(echo "$patch_full" | grep -oP '^\d+')
|
||||||
|
suffix=$(echo "$patch_full" | grep -oP '[^0-9].*' || true)
|
||||||
|
new_version="${major}.${minor}.$((patch + 1))${suffix}"
|
||||||
|
|
||||||
|
# addon.xml aktualisieren
|
||||||
|
sed -i "s/version=\"${current}\"/version=\"${new_version}\"/" addon/addon.xml
|
||||||
|
git add addon/addon.xml
|
||||||
|
|
||||||
|
echo "[hook] Version: $current → $new_version"
|
||||||
14
scripts/install_hooks.sh
Normal file
14
scripts/install_hooks.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Installiert Git Hooks für das Dev-Workflow als Symlinks
|
||||||
|
|
||||||
|
root=$(git rev-parse --show-toplevel)
|
||||||
|
hooks_src="$root/scripts/hooks"
|
||||||
|
hooks_dst="$root/.git/hooks"
|
||||||
|
|
||||||
|
for hook in pre-commit commit-msg post-commit; do
|
||||||
|
chmod +x "$hooks_src/$hook"
|
||||||
|
ln -sf "$hooks_src/$hook" "$hooks_dst/$hook"
|
||||||
|
echo "Installiert: $hook"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Alle Hooks aktiv."
|
||||||
@@ -374,50 +374,55 @@ def test_stream_link_for_movie_cache_miss(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests: _best_src_from_videos
|
# Tests: _hosters_from_videos
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_best_src_prefers_vidara_over_fallback():
|
def test_hosters_skips_gupload():
|
||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
videos = [
|
videos = [
|
||||||
{"src": "https://moflix-stream.link/e/abc", "quality": "1080p"},
|
{"src": "https://gupload.xyz/data/e/hash", "name": "GUpload"},
|
||||||
{"src": "https://vidara.to/e/xyz789", "quality": "1080p"},
|
{"src": "https://moflix-stream.link/e/abc", "name": "Mirror-HDCloud"},
|
||||||
]
|
]
|
||||||
assert plugin._best_src_from_videos(videos) == "https://vidara.to/e/xyz789"
|
hosters = plugin._hosters_from_videos(videos)
|
||||||
|
assert "https://gupload.xyz/data/e/hash" not in hosters.values()
|
||||||
|
assert "https://moflix-stream.link/e/abc" in hosters.values()
|
||||||
|
|
||||||
|
|
||||||
def test_best_src_skips_gupload():
|
def test_hosters_skips_youtube():
|
||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
videos = [
|
videos = [
|
||||||
{"src": "https://gupload.xyz/data/e/hash", "quality": "1080p"},
|
{"src": "https://youtube.com/watch?v=xyz", "name": "YouTube"},
|
||||||
{"src": "https://moflix-stream.link/e/abc", "quality": "1080p"},
|
{"src": "https://vidara.to/e/real123", "name": "Vidara"},
|
||||||
]
|
]
|
||||||
# gupload übersprungen, moflix-stream.link als Fallback
|
hosters = plugin._hosters_from_videos(videos)
|
||||||
assert plugin._best_src_from_videos(videos) == "https://moflix-stream.link/e/abc"
|
assert len(hosters) == 1
|
||||||
|
assert "https://vidara.to/e/real123" in hosters.values()
|
||||||
|
|
||||||
|
|
||||||
def test_best_src_skips_youtube():
|
def test_hosters_all_skipped_returns_empty():
|
||||||
plugin = MoflixPlugin()
|
|
||||||
videos = [
|
|
||||||
{"src": "https://youtube.com/watch?v=xyz", "quality": None},
|
|
||||||
{"src": "https://vidara.to/e/real123", "quality": "1080p"},
|
|
||||||
]
|
|
||||||
assert plugin._best_src_from_videos(videos) == "https://vidara.to/e/real123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_best_src_all_skipped_returns_none():
|
|
||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
videos = [
|
videos = [
|
||||||
{"src": "https://gupload.xyz/data/e/hash"},
|
{"src": "https://gupload.xyz/data/e/hash"},
|
||||||
{"src": "https://youtube.com/watch?v=xyz"},
|
{"src": "https://youtube.com/watch?v=xyz"},
|
||||||
]
|
]
|
||||||
assert plugin._best_src_from_videos(videos) is None
|
assert plugin._hosters_from_videos(videos) == {}
|
||||||
|
|
||||||
|
|
||||||
def test_best_src_empty_returns_none():
|
def test_hosters_empty_returns_empty():
|
||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
assert plugin._best_src_from_videos([]) is None
|
assert plugin._hosters_from_videos([]) == {}
|
||||||
assert plugin._best_src_from_videos(None) is None # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
def test_available_hosters_for_returns_names():
|
||||||
|
plugin = MoflixPlugin()
|
||||||
|
videos = [
|
||||||
|
{"src": "https://vidara.to/e/xyz", "name": "Vidara-720"},
|
||||||
|
{"src": "https://moflix-stream.click/e/abc", "name": "Mirror-HDCloud"},
|
||||||
|
]
|
||||||
|
# Mock _videos_for um direkt zu testen
|
||||||
|
plugin._videos_for = lambda *a, **kw: videos # type: ignore[assignment]
|
||||||
|
names = plugin.available_hosters_for("Test", "Film", "Test")
|
||||||
|
assert len(names) == 2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -472,7 +477,7 @@ def test_channel_empty_response_returns_empty(monkeypatch):
|
|||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: None)
|
monkeypatch.setattr(plugin, "_get_json", lambda url, headers=None: None)
|
||||||
assert plugin.popular_series() == []
|
assert plugin.popular_series() == []
|
||||||
assert plugin.latest_titles() == []
|
assert plugin.new_titles() == []
|
||||||
|
|
||||||
|
|
||||||
def test_channel_malformed_response_returns_empty(monkeypatch):
|
def test_channel_malformed_response_returns_empty(monkeypatch):
|
||||||
@@ -523,7 +528,7 @@ def test_capabilities():
|
|||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
caps = plugin.capabilities()
|
caps = plugin.capabilities()
|
||||||
assert "popular_series" in caps
|
assert "popular_series" in caps
|
||||||
assert "latest_titles" in caps
|
assert "new_titles" in caps
|
||||||
assert "genres" in caps
|
assert "genres" in caps
|
||||||
assert "collections" in caps
|
assert "collections" in caps
|
||||||
|
|
||||||
@@ -670,25 +675,26 @@ def test_resolve_stream_link_vidhide_fallback_on_failure(monkeypatch):
|
|||||||
# Tests: _best_src_from_videos – moflix-stream.click nicht mehr übersprungen
|
# Tests: _best_src_from_videos – moflix-stream.click nicht mehr übersprungen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_best_src_vidhide_not_skipped():
|
def test_hosters_vidhide_not_skipped():
|
||||||
"""moflix-stream.click ist nicht mehr in _VIDEO_SKIP_DOMAINS."""
|
"""moflix-stream.click ist nicht mehr in _VIDEO_SKIP_DOMAINS."""
|
||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
videos = [
|
videos = [
|
||||||
{"src": "https://moflix-stream.click/embed/abc123", "quality": "1080p"},
|
{"src": "https://moflix-stream.click/embed/abc123", "name": "Mirror-VidHide"},
|
||||||
]
|
]
|
||||||
result = plugin._best_src_from_videos(videos)
|
hosters = plugin._hosters_from_videos(videos)
|
||||||
assert result == "https://moflix-stream.click/embed/abc123"
|
assert "https://moflix-stream.click/embed/abc123" in hosters.values()
|
||||||
|
|
||||||
|
|
||||||
def test_best_src_vidara_preferred_over_vidhide():
|
def test_hosters_vidara_present():
|
||||||
"""vidara.to hat Vorrang vor moflix-stream.click."""
|
"""vidara.to wird korrekt als Hoster erkannt."""
|
||||||
plugin = MoflixPlugin()
|
plugin = MoflixPlugin()
|
||||||
videos = [
|
videos = [
|
||||||
{"src": "https://moflix-stream.click/embed/abc123", "quality": "1080p"},
|
{"src": "https://moflix-stream.click/embed/abc123", "name": "Mirror-VidHide"},
|
||||||
{"src": "https://vidara.to/e/xyz789", "quality": "1080p"},
|
{"src": "https://vidara.to/e/xyz789", "name": "Vidara-720"},
|
||||||
]
|
]
|
||||||
result = plugin._best_src_from_videos(videos)
|
hosters = plugin._hosters_from_videos(videos)
|
||||||
assert result == "https://vidara.to/e/xyz789"
|
assert len(hosters) == 2
|
||||||
|
assert "https://vidara.to/e/xyz789" in hosters.values()
|
||||||
|
|
||||||
|
|
||||||
def test_stream_link_for_movie_vidhide_only(monkeypatch):
|
def test_stream_link_for_movie_vidhide_only(monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user