Compare commits

..

5 Commits

28 changed files with 982 additions and 840 deletions

View File

@@ -2,41 +2,37 @@
<img src="addon/resources/logo.png" alt="ViewIT Logo" width="220" /> <img src="addon/resources/logo.png" alt="ViewIT Logo" width="220" />
ViewIT ist ein KodiAddon zum Durchsuchen und Abspielen von Inhalten der unterstützten Anbieter. ViewIT ist ein Kodi Addon.
Es durchsucht Provider und startet Streams.
## Projektstruktur ## Projektstruktur
- `addon/` KodiAddon Quellcode - `addon/` Kodi Addon Quellcode
- `scripts/` BuildScripts (arbeiten mit `addon/` + `dist/`) - `scripts/` Build Scripts
- `dist/` BuildAusgaben (ZIPs) - `dist/` Build Ausgaben
- `docs/`, `tests/` - `docs/` Doku
- `tests/` Tests
## Build & Release ## Build und Release
- AddonOrdner bauen: `./scripts/build_install_addon.sh``dist/<addon_id>/` - Addon Ordner bauen: `./scripts/build_install_addon.sh`
- KodiZIP bauen: `./scripts/build_kodi_zip.sh``dist/<addon_id>-<version>.zip` - Kodi ZIP bauen: `./scripts/build_kodi_zip.sh`
- AddonVersion in `addon/addon.xml` - Version pflegen: `addon/addon.xml`
- Reproduzierbare ZIPs: optional `SOURCE_DATE_EPOCH` setzen - Reproduzierbares ZIP: `SOURCE_DATE_EPOCH` optional setzen
## Lokales Kodi-Repository ## Lokales Kodi Repository
- Repository bauen (inkl. ZIPs + `addons.xml` + `addons.xml.md5`): `./scripts/build_local_kodi_repo.sh` - Repository bauen: `./scripts/build_local_kodi_repo.sh`
- Lokal bereitstellen: `./scripts/serve_local_kodi_repo.sh` - Repository starten: `./scripts/serve_local_kodi_repo.sh`
- Standard-URL: `http://127.0.0.1:8080/repo/addons.xml` - Standard URL: `http://127.0.0.1:8080/repo/addons.xml`
- Optional eigene URL beim Build setzen: `REPO_BASE_URL=http://<host>:<port>/repo ./scripts/build_local_kodi_repo.sh` - Eigene URL beim Build: `REPO_BASE_URL=http://<host>:<port>/repo ./scripts/build_local_kodi_repo.sh`
## Gitea Release-Asset Upload ## Entwicklung
- ZIP bauen: `./scripts/build_kodi_zip.sh` - Router: `addon/default.py`
- Token setzen: `export GITEA_TOKEN=<token>`
- Asset an Tag hochladen (erstellt Release bei Bedarf): `./scripts/publish_gitea_release.sh`
- Optional: `--tag v0.1.50 --asset dist/plugin.video.viewit-0.1.50.zip`
## Entwicklung (kurz)
- Hauptlogik: `addon/default.py`
- Plugins: `addon/plugins/*_plugin.py` - Plugins: `addon/plugins/*_plugin.py`
- Einstellungen: `addon/resources/settings.xml` - Settings: `addon/resources/settings.xml`
## Tests mit Abdeckung ## Tests
- Dev-Abhängigkeiten installieren: `./.venv/bin/pip install -r requirements-dev.txt` - Dev Pakete installieren: `./.venv/bin/pip install -r requirements-dev.txt`
- Tests + Coverage starten: `./.venv/bin/pytest` - Tests starten: `./.venv/bin/pytest`
- Optional (XML-Report): `./.venv/bin/pytest --cov-report=xml` - XML Report: `./.venv/bin/pytest --cov-report=xml`
## Dokumentation ## Dokumentation
Siehe `docs/`. Siehe `docs/`.

View File

@@ -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.56" provider-name="ViewIt"> <addon id="plugin.video.viewit" name="ViewIt" version="0.1.57" 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" />
@@ -10,8 +10,8 @@
<provides>video</provides> <provides>video</provides>
</extension> </extension>
<extension point="xbmc.addon.metadata"> <extension point="xbmc.addon.metadata">
<summary>ViewIt Kodi Plugin</summary> <summary>Suche und Wiedergabe fuer mehrere Quellen</summary>
<description>Streaming-Addon für Streamingseiten: Suche, Staffeln/Episoden und Wiedergabe.</description> <description>Findet Titel in unterstuetzten Quellen und startet Filme oder Episoden direkt in Kodi.</description>
<assets> <assets>
<icon>icon.png</icon> <icon>icon.png</icon>
</assets> </assets>

View File

@@ -8,6 +8,7 @@ ruft Plugin-Implementierungen auf und startet die Wiedergabe.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import atexit
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime from datetime import datetime
import importlib.util import importlib.util
@@ -102,6 +103,13 @@ except ImportError: # pragma: no cover - allow importing outside Kodi (e.g. lin
xbmcplugin = _XbmcPluginStub() xbmcplugin = _XbmcPluginStub()
from plugin_interface import BasisPlugin from plugin_interface import BasisPlugin
from http_session_pool import close_all_sessions
from metadata_utils import (
collect_plugin_metadata as _collect_plugin_metadata,
merge_metadata as _merge_metadata,
metadata_policy as _metadata_policy_impl,
needs_tmdb as _needs_tmdb,
)
from tmdb import TmdbCastMember, fetch_tv_episode_credits, lookup_movie, lookup_tv_season, lookup_tv_season_summary, lookup_tv_show from tmdb import TmdbCastMember, fetch_tv_episode_credits, lookup_movie, lookup_tv_season, lookup_tv_season_summary, lookup_tv_show
PLUGIN_DIR = Path(__file__).with_name("plugins") PLUGIN_DIR = Path(__file__).with_name("plugins")
@@ -116,8 +124,22 @@ _TMDB_LOG_PATH: str | None = None
_GENRE_TITLES_CACHE: dict[tuple[str, str], list[str]] = {} _GENRE_TITLES_CACHE: dict[tuple[str, str], list[str]] = {}
_ADDON_INSTANCE = None _ADDON_INSTANCE = None
_PLAYSTATE_CACHE: dict[str, dict[str, object]] | None = None _PLAYSTATE_CACHE: dict[str, dict[str, object]] | None = None
_PLAYSTATE_LOCK = threading.RLock()
_TMDB_LOCK = threading.RLock()
WATCHED_THRESHOLD = 0.9 WATCHED_THRESHOLD = 0.9
atexit.register(close_all_sessions)
def _tmdb_cache_get(cache: dict, key, default=None):
with _TMDB_LOCK:
return cache.get(key, default)
def _tmdb_cache_set(cache: dict, key, value) -> None:
with _TMDB_LOCK:
cache[key] = value
def _tmdb_prefetch_concurrency() -> int: def _tmdb_prefetch_concurrency() -> int:
"""Max number of concurrent TMDB lookups when prefetching metadata for lists.""" """Max number of concurrent TMDB lookups when prefetching metadata for lists."""
@@ -155,12 +177,19 @@ def _busy_close() -> None:
@contextmanager @contextmanager
def _busy_dialog(): def _busy_dialog(message: str = "Bitte warten...", *, heading: str = "Bitte warten"):
_busy_open() """Progress-Dialog statt Spinner, mit kurzem Status-Text."""
try: with _progress_dialog(heading, message) as progress:
yield progress(10, message)
finally:
_busy_close() def _update(step_message: str, percent: int | None = None) -> bool:
pct = 50 if percent is None else max(5, min(95, int(percent)))
return progress(pct, step_message or message)
try:
yield _update
finally:
progress(100, "Fertig")
@contextmanager @contextmanager
@@ -202,6 +231,33 @@ def _progress_dialog(heading: str, message: str = ""):
pass pass
def _method_accepts_kwarg(method: object, kwarg_name: str) -> bool:
if not callable(method):
return False
try:
signature = inspect.signature(method)
except Exception:
return False
for param in signature.parameters.values():
if param.kind == inspect.Parameter.VAR_KEYWORD:
return True
if param.name == kwarg_name and param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
return True
return False
def _call_plugin_search(plugin: BasisPlugin, query: str, *, progress_callback=None):
method = getattr(plugin, "search_titles", None)
if not callable(method):
raise RuntimeError("Plugin hat keine gueltige search_titles Methode.")
if progress_callback is not None and _method_accepts_kwarg(method, "progress_callback"):
return method(query, progress_callback=progress_callback)
return method(query)
def _get_handle() -> int: def _get_handle() -> int:
return int(sys.argv[1]) if len(sys.argv) > 1 else -1 return int(sys.argv[1]) if len(sys.argv) > 1 else -1
@@ -242,52 +298,54 @@ def _playstate_path() -> str:
def _load_playstate() -> dict[str, dict[str, object]]: def _load_playstate() -> dict[str, dict[str, object]]:
global _PLAYSTATE_CACHE global _PLAYSTATE_CACHE
if _PLAYSTATE_CACHE is not None: with _PLAYSTATE_LOCK:
return _PLAYSTATE_CACHE if _PLAYSTATE_CACHE is not None:
path = _playstate_path() return _PLAYSTATE_CACHE
try: path = _playstate_path()
if xbmcvfs and xbmcvfs.exists(path): try:
handle = xbmcvfs.File(path) if xbmcvfs and xbmcvfs.exists(path):
raw = handle.read() handle = xbmcvfs.File(path)
handle.close()
else:
with open(path, "r", encoding="utf-8") as handle:
raw = handle.read() raw = handle.read()
data = json.loads(raw or "{}") handle.close()
if isinstance(data, dict): else:
normalized: dict[str, dict[str, object]] = {} with open(path, "r", encoding="utf-8") as handle:
for key, value in data.items(): raw = handle.read()
if isinstance(key, str) and isinstance(value, dict): data = json.loads(raw or "{}")
normalized[key] = dict(value) if isinstance(data, dict):
_PLAYSTATE_CACHE = normalized normalized: dict[str, dict[str, object]] = {}
return normalized for key, value in data.items():
except Exception: if isinstance(key, str) and isinstance(value, dict):
pass normalized[key] = dict(value)
_PLAYSTATE_CACHE = {} _PLAYSTATE_CACHE = normalized
return {} return normalized
except Exception:
pass
_PLAYSTATE_CACHE = {}
return {}
def _save_playstate(state: dict[str, dict[str, object]]) -> None: def _save_playstate(state: dict[str, dict[str, object]]) -> None:
global _PLAYSTATE_CACHE global _PLAYSTATE_CACHE
_PLAYSTATE_CACHE = state with _PLAYSTATE_LOCK:
path = _playstate_path() _PLAYSTATE_CACHE = state
try: path = _playstate_path()
payload = json.dumps(state, ensure_ascii=False, sort_keys=True) try:
except Exception: payload = json.dumps(state, ensure_ascii=False, sort_keys=True)
return except Exception:
try: return
if xbmcvfs: try:
directory = os.path.dirname(path) if xbmcvfs:
if directory and not xbmcvfs.exists(directory): directory = os.path.dirname(path)
xbmcvfs.mkdirs(directory) if directory and not xbmcvfs.exists(directory):
handle = xbmcvfs.File(path, "w") xbmcvfs.mkdirs(directory)
handle.write(payload) handle = xbmcvfs.File(path, "w")
handle.close()
else:
with open(path, "w", encoding="utf-8") as handle:
handle.write(payload) handle.write(payload)
except Exception: handle.close()
return else:
with open(path, "w", encoding="utf-8") as handle:
handle.write(payload)
except Exception:
return
def _get_playstate(key: str) -> dict[str, object]: def _get_playstate(key: str) -> dict[str, object]:
@@ -452,40 +510,18 @@ def _get_setting_int(setting_id: str, *, default: int = 0) -> int:
return default return default
METADATA_MODE_AUTO = 0
METADATA_MODE_SOURCE = 1
METADATA_MODE_TMDB = 2
METADATA_MODE_MIX = 3
def _metadata_setting_id(plugin_name: str) -> str:
safe = re.sub(r"[^a-z0-9]+", "_", (plugin_name or "").strip().casefold()).strip("_")
return f"{safe}_metadata_source" if safe else "metadata_source"
def _plugin_supports_metadata(plugin: BasisPlugin) -> bool:
try:
return plugin.__class__.metadata_for is not BasisPlugin.metadata_for
except Exception:
return False
def _metadata_policy( def _metadata_policy(
plugin_name: str, plugin_name: str,
plugin: BasisPlugin, plugin: BasisPlugin,
*, *,
allow_tmdb: bool, allow_tmdb: bool,
) -> tuple[bool, bool, bool]: ) -> tuple[bool, bool, bool]:
mode = _get_setting_int(_metadata_setting_id(plugin_name), default=METADATA_MODE_AUTO) return _metadata_policy_impl(
supports_source = _plugin_supports_metadata(plugin) plugin_name,
if mode == METADATA_MODE_SOURCE: plugin,
return supports_source, False, True allow_tmdb=allow_tmdb,
if mode == METADATA_MODE_TMDB: get_setting_int=_get_setting_int,
return False, allow_tmdb, False )
if mode == METADATA_MODE_MIX:
return supports_source, allow_tmdb, True
prefer_source = bool(getattr(plugin, "prefer_source_metadata", False))
return supports_source, allow_tmdb, prefer_source
def _tmdb_list_enabled() -> bool: def _tmdb_list_enabled() -> bool:
@@ -715,11 +751,11 @@ def _tmdb_labels_and_art(title: str) -> tuple[dict[str, str], dict[str, str], li
show_cast = _get_setting_bool("tmdb_show_cast", default=False) show_cast = _get_setting_bool("tmdb_show_cast", default=False)
flags = f"p{int(show_plot)}a{int(show_art)}f{int(show_fanart)}r{int(show_rating)}v{int(show_votes)}c{int(show_cast)}" flags = f"p{int(show_plot)}a{int(show_art)}f{int(show_fanart)}r{int(show_rating)}v{int(show_votes)}c{int(show_cast)}"
cache_key = f"{language}|{flags}|{title_key}" cache_key = f"{language}|{flags}|{title_key}"
cached = _TMDB_CACHE.get(cache_key) cached = _tmdb_cache_get(_TMDB_CACHE, cache_key)
if cached is not None: if cached is not None:
info, art = cached info, art = cached
# Cast wird nicht in _TMDB_CACHE gehalten (weil es ListItem.setCast betrifft), daher separat cachen: # Cast wird nicht in _TMDB_CACHE gehalten (weil es ListItem.setCast betrifft), daher separat cachen:
cast_cached = _TMDB_CAST_CACHE.get(cache_key, []) cast_cached = _tmdb_cache_get(_TMDB_CAST_CACHE, cache_key, [])
return info, art, list(cast_cached) return info, art, list(cast_cached)
info_labels: dict[str, str] = {"title": title} info_labels: dict[str, str] = {"title": title}
@@ -777,7 +813,7 @@ def _tmdb_labels_and_art(title: str) -> tuple[dict[str, str], dict[str, str], li
if meta: if meta:
# Nur TV-IDs cachen (für Staffel-/Episoden-Lookups); Movie-IDs würden dort fehlschlagen. # Nur TV-IDs cachen (für Staffel-/Episoden-Lookups); Movie-IDs würden dort fehlschlagen.
if is_tv: if is_tv:
_TMDB_ID_CACHE[title_key] = int(getattr(meta, "tmdb_id", 0) or 0) _tmdb_cache_set(_TMDB_ID_CACHE, title_key, int(getattr(meta, "tmdb_id", 0) or 0))
info_labels.setdefault("mediatype", "tvshow") info_labels.setdefault("mediatype", "tvshow")
else: else:
info_labels.setdefault("mediatype", "movie") info_labels.setdefault("mediatype", "movie")
@@ -805,8 +841,8 @@ def _tmdb_labels_and_art(title: str) -> tuple[dict[str, str], dict[str, str], li
elif log_requests or log_responses: elif log_requests or log_responses:
_tmdb_file_log(f"TMDB MISS title={title!r}") _tmdb_file_log(f"TMDB MISS title={title!r}")
_TMDB_CACHE[cache_key] = (info_labels, art) _tmdb_cache_set(_TMDB_CACHE, cache_key, (info_labels, art))
_TMDB_CAST_CACHE[cache_key] = list(cast) _tmdb_cache_set(_TMDB_CAST_CACHE, cache_key, list(cast))
return info_labels, art, list(cast) return info_labels, art, list(cast)
@@ -852,10 +888,10 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label
if not _tmdb_enabled(): if not _tmdb_enabled():
return {"title": episode_label}, {} return {"title": episode_label}, {}
title_key = (title or "").strip().casefold() title_key = (title or "").strip().casefold()
tmdb_id = _TMDB_ID_CACHE.get(title_key) tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key)
if not tmdb_id: if not tmdb_id:
_tmdb_labels_and_art(title) _tmdb_labels_and_art(title)
tmdb_id = _TMDB_ID_CACHE.get(title_key) tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key)
if not tmdb_id: if not tmdb_id:
return {"title": episode_label}, {} return {"title": episode_label}, {}
@@ -869,7 +905,7 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label
show_art = _get_setting_bool("tmdb_show_art", default=True) show_art = _get_setting_bool("tmdb_show_art", default=True)
flags = f"p{int(show_plot)}a{int(show_art)}" flags = f"p{int(show_plot)}a{int(show_art)}"
season_key = (tmdb_id, season_number, language, flags) season_key = (tmdb_id, season_number, language, flags)
cached_season = _TMDB_SEASON_CACHE.get(season_key) cached_season = _tmdb_cache_get(_TMDB_SEASON_CACHE, season_key)
if cached_season is None: if cached_season is None:
api_key = _get_setting_string("tmdb_api_key").strip() api_key = _get_setting_string("tmdb_api_key").strip()
if not api_key: if not api_key:
@@ -902,7 +938,7 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label
if show_art and ep.thumb: if show_art and ep.thumb:
art = {"thumb": ep.thumb} art = {"thumb": ep.thumb}
mapped[ep_no] = (info, art) mapped[ep_no] = (info, art)
_TMDB_SEASON_CACHE[season_key] = mapped _tmdb_cache_set(_TMDB_SEASON_CACHE, season_key, mapped)
cached_season = mapped cached_season = mapped
return cached_season.get(episode_number, ({"title": episode_label}, {})) return cached_season.get(episode_number, ({"title": episode_label}, {}))
@@ -916,10 +952,10 @@ def _tmdb_episode_cast(*, title: str, season_label: str, episode_label: str) ->
return [] return []
title_key = (title or "").strip().casefold() title_key = (title or "").strip().casefold()
tmdb_id = _TMDB_ID_CACHE.get(title_key) tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key)
if not tmdb_id: if not tmdb_id:
_tmdb_labels_and_art(title) _tmdb_labels_and_art(title)
tmdb_id = _TMDB_ID_CACHE.get(title_key) tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key)
if not tmdb_id: if not tmdb_id:
return [] return []
@@ -930,13 +966,13 @@ def _tmdb_episode_cast(*, title: str, season_label: str, episode_label: str) ->
language = _get_setting_string("tmdb_language").strip() or "de-DE" language = _get_setting_string("tmdb_language").strip() or "de-DE"
cache_key = (tmdb_id, season_number, episode_number, language) cache_key = (tmdb_id, season_number, episode_number, language)
cached = _TMDB_EPISODE_CAST_CACHE.get(cache_key) cached = _tmdb_cache_get(_TMDB_EPISODE_CAST_CACHE, cache_key)
if cached is not None: if cached is not None:
return list(cached) return list(cached)
api_key = _get_setting_string("tmdb_api_key").strip() api_key = _get_setting_string("tmdb_api_key").strip()
if not api_key: if not api_key:
_TMDB_EPISODE_CAST_CACHE[cache_key] = [] _tmdb_cache_set(_TMDB_EPISODE_CAST_CACHE, cache_key, [])
return [] return []
log_requests = _get_setting_bool("tmdb_log_requests", default=False) log_requests = _get_setting_bool("tmdb_log_requests", default=False)
@@ -958,7 +994,7 @@ def _tmdb_episode_cast(*, title: str, season_label: str, episode_label: str) ->
f"TMDB ERROR episode_credits_failed tmdb_id={tmdb_id} season={season_number} episode={episode_number} error={exc!r}" f"TMDB ERROR episode_credits_failed tmdb_id={tmdb_id} season={season_number} episode={episode_number} error={exc!r}"
) )
cast = [] cast = []
_TMDB_EPISODE_CAST_CACHE[cache_key] = list(cast) _tmdb_cache_set(_TMDB_EPISODE_CAST_CACHE, cache_key, list(cast))
return list(cast) return list(cast)
@@ -1053,52 +1089,6 @@ def _settings_key_for_plugin(name: str) -> str:
return f"update_version_{safe}" if safe else "update_version_unknown" return f"update_version_{safe}" if safe else "update_version_unknown"
def _collect_plugin_metadata(plugin: BasisPlugin, titles: list[str]) -> dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None]]:
getter = getattr(plugin, "metadata_for", None)
if not callable(getter):
return {}
collected: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None]] = {}
for title in titles:
try:
labels, art, cast = getter(title)
except Exception:
continue
if isinstance(labels, dict) or isinstance(art, dict) or cast:
label_map = {str(k): str(v) for k, v in dict(labels or {}).items() if v}
art_map = {str(k): str(v) for k, v in dict(art or {}).items() if v}
collected[title] = (label_map, art_map, cast if isinstance(cast, list) else None)
return collected
def _needs_tmdb(labels: dict[str, str], art: dict[str, str], *, want_plot: bool, want_art: bool) -> bool:
if want_plot and not labels.get("plot"):
return True
if want_art and not (art.get("thumb") or art.get("poster") or art.get("fanart") or art.get("landscape")):
return True
return False
def _merge_metadata(
title: str,
tmdb_labels: dict[str, str] | None,
tmdb_art: dict[str, str] | None,
tmdb_cast: list[TmdbCastMember] | None,
plugin_meta: tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None] | None,
) -> tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None]:
labels = dict(tmdb_labels or {})
art = dict(tmdb_art or {})
cast = tmdb_cast
if plugin_meta is not None:
meta_labels, meta_art, meta_cast = plugin_meta
labels.update({k: str(v) for k, v in dict(meta_labels or {}).items() if v})
art.update({k: str(v) for k, v in dict(meta_art or {}).items() if v})
if meta_cast is not None:
cast = meta_cast
if "title" not in labels:
labels["title"] = title
return labels, art, cast
def _sync_update_version_settings() -> None: def _sync_update_version_settings() -> None:
addon = _get_addon() addon = _get_addon()
addon_version = "0.0.0" addon_version = "0.0.0"
@@ -1128,7 +1118,7 @@ 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.")
_add_directory_item(handle, "Globale Suche", "search") _add_directory_item(handle, "Suche in allen Quellen", "search")
plugins = _discover_plugins() plugins = _discover_plugins()
for plugin_name in sorted(plugins.keys(), key=lambda value: value.casefold()): for plugin_name in sorted(plugins.keys(), key=lambda value: value.casefold()):
@@ -1143,7 +1133,7 @@ def _show_plugin_menu(plugin_name: str) -> None:
plugin_name = (plugin_name or "").strip() plugin_name = (plugin_name or "").strip()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if not plugin: if not plugin:
xbmcgui.Dialog().notification("Plugin", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Quelle", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -1167,7 +1157,7 @@ def _show_plugin_menu(plugin_name: str) -> None:
_add_directory_item(handle, "Serien", "series_catalog", {"plugin": plugin_name, "page": "1"}, is_folder=True) _add_directory_item(handle, "Serien", "series_catalog", {"plugin": plugin_name, "page": "1"}, is_folder=True)
if _plugin_has_capability(plugin, "popular_series"): if _plugin_has_capability(plugin, "popular_series"):
_add_directory_item(handle, "Meist gesehen", "popular", {"plugin": plugin_name, "page": "1"}, is_folder=True) _add_directory_item(handle, "Beliebte Serien", "popular", {"plugin": plugin_name, "page": "1"}, is_folder=True)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
@@ -1176,7 +1166,7 @@ def _show_plugin_search(plugin_name: str) -> None:
plugin_name = (plugin_name or "").strip() plugin_name = (plugin_name or "").strip()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if not plugin: if not plugin:
xbmcgui.Dialog().notification("Suche", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Suche", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
_show_root_menu() _show_root_menu()
return return
@@ -1197,7 +1187,7 @@ def _show_plugin_search_results(plugin_name: str, query: str) -> None:
query = (query or "").strip() query = (query or "").strip()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if not plugin: if not plugin:
xbmcgui.Dialog().notification("Suche", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Suche", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -1208,9 +1198,13 @@ def _show_plugin_search_results(plugin_name: str, query: str) -> None:
list_items: list[dict[str, object]] = [] list_items: list[dict[str, object]] = []
canceled = False canceled = False
try: try:
with _progress_dialog("Suche läuft", f"{plugin_name} (1/1) starte") as progress: with _progress_dialog("Suche laeuft", f"{plugin_name} (1/1) startet...") as progress:
canceled = progress(5, f"{plugin_name} (1/1) Suche") canceled = progress(5, f"{plugin_name} (1/1) Suche...")
search_coro = plugin.search_titles(query) plugin_progress = lambda msg="", pct=None: progress( # noqa: E731 - kompakte Callback-Bruecke
max(5, min(95, int(pct))) if pct is not None else 20,
f"{plugin_name} (1/1) {str(msg or 'Suche...').strip()}",
)
search_coro = _call_plugin_search(plugin, query, progress_callback=plugin_progress)
try: try:
results = _run_async(search_coro) results = _run_async(search_coro)
except Exception: except Exception:
@@ -1240,7 +1234,7 @@ def _show_plugin_search_results(plugin_name: str, query: str) -> None:
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles and not canceled: if show_tmdb and tmdb_titles and not canceled:
canceled = progress(35, f"{plugin_name} (1/1) Metadaten") canceled = progress(35, f"{plugin_name} (1/1) Metadaten...")
tmdb_prefetched = _tmdb_labels_and_art_bulk(list(tmdb_titles)) tmdb_prefetched = _tmdb_labels_and_art_bulk(list(tmdb_titles))
total_results = max(1, len(results)) total_results = max(1, len(results))
@@ -1415,7 +1409,7 @@ def _series_url_params(plugin: BasisPlugin, title: str) -> dict[str, str]:
def _show_search() -> None: def _show_search() -> None:
_log("Suche gestartet.") _log("Suche gestartet.")
dialog = xbmcgui.Dialog() dialog = xbmcgui.Dialog()
query = dialog.input("Serientitel eingeben", type=xbmcgui.INPUT_ALPHANUM).strip() query = dialog.input("Titel eingeben", type=xbmcgui.INPUT_ALPHANUM).strip()
if not query: if not query:
_log("Suche abgebrochen (leere Eingabe).", xbmc.LOGDEBUG) _log("Suche abgebrochen (leere Eingabe).", xbmc.LOGDEBUG)
_show_root_menu() _show_root_menu()
@@ -1430,21 +1424,25 @@ def _show_search_results(query: str) -> None:
_set_content(handle, "tvshows") _set_content(handle, "tvshows")
plugins = _discover_plugins() plugins = _discover_plugins()
if not plugins: if not plugins:
xbmcgui.Dialog().notification("Suche", "Keine Plugins gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Suche", "Keine Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
list_items: list[dict[str, object]] = [] list_items: list[dict[str, object]] = []
canceled = False canceled = False
plugin_entries = list(plugins.items()) plugin_entries = list(plugins.items())
total_plugins = max(1, len(plugin_entries)) total_plugins = max(1, len(plugin_entries))
with _progress_dialog("Suche läuft", "Suche gestartet") as progress: with _progress_dialog("Suche laeuft", "Suche startet...") as progress:
for plugin_index, (plugin_name, plugin) in enumerate(plugin_entries, start=1): for plugin_index, (plugin_name, plugin) in enumerate(plugin_entries, start=1):
range_start = int(((plugin_index - 1) / float(total_plugins)) * 100) range_start = int(((plugin_index - 1) / float(total_plugins)) * 100)
range_end = int((plugin_index / float(total_plugins)) * 100) range_end = int((plugin_index / float(total_plugins)) * 100)
canceled = progress(range_start, f"{plugin_name} ({plugin_index}/{total_plugins}) Suche") canceled = progress(range_start, f"{plugin_name} ({plugin_index}/{total_plugins}) Suche...")
if canceled: if canceled:
break break
search_coro = plugin.search_titles(query) plugin_progress = lambda msg="", pct=None: progress( # noqa: E731 - kompakte Callback-Bruecke
max(range_start, min(range_end, int(pct))) if pct is not None else range_start + 20,
f"{plugin_name} ({plugin_index}/{total_plugins}) {str(msg or 'Suche...').strip()}",
)
search_coro = _call_plugin_search(plugin, query, progress_callback=plugin_progress)
try: try:
results = _run_async(search_coro) results = _run_async(search_coro)
except Exception as exc: except Exception as exc:
@@ -1476,7 +1474,7 @@ def _show_search_results(query: str) -> None:
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
canceled = progress( canceled = progress(
range_start + int((range_end - range_start) * 0.35), range_start + int((range_end - range_start) * 0.35),
f"{plugin_name} ({plugin_index}/{total_plugins}) Metadaten", f"{plugin_name} ({plugin_index}/{total_plugins}) Metadaten...",
) )
if canceled: if canceled:
break break
@@ -1519,7 +1517,7 @@ def _show_search_results(query: str) -> None:
if canceled: if canceled:
break break
if not canceled: if not canceled:
progress(100, "Suche abgeschlossen") progress(100, "Suche fertig")
if canceled and not list_items: if canceled and not list_items:
xbmcgui.Dialog().notification("Suche", "Suche abgebrochen.", xbmcgui.NOTIFICATION_INFO, 2500) xbmcgui.Dialog().notification("Suche", "Suche abgebrochen.", xbmcgui.NOTIFICATION_INFO, 2500)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
@@ -1544,7 +1542,7 @@ def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None:
_log(f"Staffeln laden: {plugin_name} / {title}") _log(f"Staffeln laden: {plugin_name} / {title}")
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Staffeln", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Staffeln", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
if series_url: if series_url:
@@ -1618,7 +1616,7 @@ def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None:
meta_getter = getattr(plugin, "metadata_for", None) meta_getter = getattr(plugin, "metadata_for", None)
if use_source and callable(meta_getter): if use_source and callable(meta_getter):
try: try:
with _busy_dialog(): with _busy_dialog("Metadaten werden geladen..."):
meta_labels, meta_art, meta_cast = meta_getter(title) meta_labels, meta_art, meta_cast = meta_getter(title)
if isinstance(meta_labels, dict): if isinstance(meta_labels, dict):
title_info_labels = {str(k): str(v) for k, v in meta_labels.items() if v} title_info_labels = {str(k): str(v) for k, v in meta_labels.items() if v}
@@ -1634,7 +1632,7 @@ def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None:
seasons = plugin.seasons_for(title) seasons = plugin.seasons_for(title)
except Exception as exc: except Exception as exc:
_log(f"Staffeln laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) _log(f"Staffeln laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
xbmcgui.Dialog().notification("Staffeln", "Konnte Staffeln nicht laden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Staffeln", "Staffeln konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -1658,8 +1656,8 @@ def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None:
art: dict[str, str] | None = None art: dict[str, str] | None = None
season_number = _extract_first_int(season) season_number = _extract_first_int(season)
if api_key and season_number is not None: if api_key and season_number is not None:
cache_key = (_TMDB_ID_CACHE.get((title or "").strip().casefold(), 0), season_number, language, flags) cache_key = (_tmdb_cache_get(_TMDB_ID_CACHE, (title or "").strip().casefold(), 0), season_number, language, flags)
cached = _TMDB_SEASON_SUMMARY_CACHE.get(cache_key) cached = _tmdb_cache_get(_TMDB_SEASON_SUMMARY_CACHE, cache_key)
if cached is None and cache_key[0]: if cached is None and cache_key[0]:
try: try:
meta = lookup_tv_season_summary( meta = lookup_tv_season_summary(
@@ -1682,7 +1680,7 @@ def _show_seasons(plugin_name: str, title: str, series_url: str = "") -> None:
if show_art and meta.poster: if show_art and meta.poster:
art_map = {"thumb": meta.poster, "poster": meta.poster} art_map = {"thumb": meta.poster, "poster": meta.poster}
cached = (labels, art_map) cached = (labels, art_map)
_TMDB_SEASON_SUMMARY_CACHE[cache_key] = cached _tmdb_cache_set(_TMDB_SEASON_SUMMARY_CACHE, cache_key, cached)
if cached is not None: if cached is not None:
info_labels, art = cached info_labels, art = cached
merged_labels = dict(info_labels or {}) merged_labels = dict(info_labels or {})
@@ -1715,7 +1713,7 @@ def _show_episodes(plugin_name: str, title: str, season: str, series_url: str =
_log(f"Episoden laden: {plugin_name} / {title} / {season}") _log(f"Episoden laden: {plugin_name} / {title} / {season}")
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Episoden", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Episoden", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
if series_url: if series_url:
@@ -1748,7 +1746,7 @@ def _show_episodes(plugin_name: str, title: str, season: str, series_url: str =
meta_getter = getattr(plugin, "metadata_for", None) meta_getter = getattr(plugin, "metadata_for", None)
if callable(meta_getter): if callable(meta_getter):
try: try:
with _busy_dialog(): with _busy_dialog("Episoden-Metadaten werden geladen..."):
meta_labels, meta_art, meta_cast = meta_getter(title) meta_labels, meta_art, meta_cast = meta_getter(title)
if isinstance(meta_labels, dict): if isinstance(meta_labels, dict):
show_info = {str(k): str(v) for k, v in meta_labels.items() if v} show_info = {str(k): str(v) for k, v in meta_labels.items() if v}
@@ -1761,7 +1759,7 @@ def _show_episodes(plugin_name: str, title: str, season: str, series_url: str =
show_fanart = (show_art or {}).get("fanart") if isinstance(show_art, dict) else "" show_fanart = (show_art or {}).get("fanart") if isinstance(show_art, dict) else ""
show_poster = (show_art or {}).get("poster") if isinstance(show_art, dict) else "" show_poster = (show_art or {}).get("poster") if isinstance(show_art, dict) else ""
with _busy_dialog(): with _busy_dialog("Episoden werden aufbereitet..."):
for episode in episodes: for episode in episodes:
if show_tmdb: if show_tmdb:
info_labels, art = _tmdb_episode_labels_and_art( info_labels, art = _tmdb_episode_labels_and_art(
@@ -1839,7 +1837,7 @@ def _show_genre_sources() -> None:
sources.append((plugin_name, plugin)) sources.append((plugin_name, plugin))
if not sources: if not sources:
xbmcgui.Dialog().notification("Genres", "Keine Genre-Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Genres", "Keine Quellen mit Genres gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -1859,7 +1857,7 @@ def _show_genres(plugin_name: str) -> None:
_log(f"Genres laden: {plugin_name}") _log(f"Genres laden: {plugin_name}")
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Genres", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Genres", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
try: try:
@@ -1896,7 +1894,7 @@ def _show_categories(plugin_name: str) -> None:
_log(f"Kategorien laden: {plugin_name}") _log(f"Kategorien laden: {plugin_name}")
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Kategorien", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Kategorien", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
getter = getattr(plugin, "categories", None) getter = getattr(plugin, "categories", None)
@@ -1929,14 +1927,14 @@ def _show_category_titles_page(plugin_name: str, category: str, page: int = 1) -
handle = _get_handle() handle = _get_handle()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Kategorien", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Kategorien", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
page = max(1, int(page or 1)) page = max(1, int(page or 1))
paging_getter = getattr(plugin, "titles_for_genre_page", None) paging_getter = getattr(plugin, "titles_for_genre_page", None)
if not callable(paging_getter): if not callable(paging_getter):
xbmcgui.Dialog().notification("Kategorien", "Paging nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Kategorien", "Seitenwechsel nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -1992,7 +1990,7 @@ def _show_category_titles_page(plugin_name: str, category: str, page: int = 1) -
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("Genre-Liste wird geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in titles: for title in titles:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -2045,14 +2043,14 @@ def _show_genre_titles_page(plugin_name: str, genre: str, page: int = 1) -> None
handle = _get_handle() handle = _get_handle()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Genres", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Genres", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
page = max(1, int(page or 1)) page = max(1, int(page or 1))
paging_getter = getattr(plugin, "titles_for_genre_page", None) paging_getter = getattr(plugin, "titles_for_genre_page", None)
if not callable(paging_getter): if not callable(paging_getter):
xbmcgui.Dialog().notification("Genres", "Paging nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Genres", "Seitenwechsel nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -2108,7 +2106,7 @@ def _show_genre_titles_page(plugin_name: str, genre: str, page: int = 1) -> None
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("Genre-Seite wird geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in titles: for title in titles:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -2163,12 +2161,12 @@ def _show_alpha_index(plugin_name: str) -> None:
_log(f"A-Z laden: {plugin_name}") _log(f"A-Z laden: {plugin_name}")
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("A-Z", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("A-Z", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
getter = getattr(plugin, "alpha_index", None) getter = getattr(plugin, "alpha_index", None)
if not callable(getter): if not callable(getter):
xbmcgui.Dialog().notification("A-Z", "A-Z nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("A-Z", "A-Z nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
try: try:
@@ -2196,14 +2194,14 @@ def _show_alpha_titles_page(plugin_name: str, letter: str, page: int = 1) -> Non
handle = _get_handle() handle = _get_handle()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("A-Z", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("A-Z", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
page = max(1, int(page or 1)) page = max(1, int(page or 1))
paging_getter = getattr(plugin, "titles_for_alpha_page", None) paging_getter = getattr(plugin, "titles_for_alpha_page", None)
if not callable(paging_getter): if not callable(paging_getter):
xbmcgui.Dialog().notification("A-Z", "Paging nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("A-Z", "Seitenwechsel nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -2259,7 +2257,7 @@ def _show_alpha_titles_page(plugin_name: str, letter: str, page: int = 1) -> Non
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("A-Z Liste wird geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in titles: for title in titles:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -2308,14 +2306,14 @@ def _show_series_catalog(plugin_name: str, page: int = 1) -> None:
plugin_name = (plugin_name or "").strip() plugin_name = (plugin_name or "").strip()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Serien", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Serien", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
page = max(1, int(page or 1)) page = max(1, int(page or 1))
paging_getter = getattr(plugin, "series_catalog_page", None) paging_getter = getattr(plugin, "series_catalog_page", None)
if not callable(paging_getter): if not callable(paging_getter):
xbmcgui.Dialog().notification("Serien", "Serien nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Serien", "Serienkatalog nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -2371,7 +2369,7 @@ def _show_series_catalog(plugin_name: str, page: int = 1) -> None:
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("A-Z Seite wird geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in titles: for title in titles:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -2548,7 +2546,7 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None:
if plugin_name: if plugin_name:
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None or not _plugin_has_capability(plugin, "popular_series"): if plugin is None or not _plugin_has_capability(plugin, "popular_series"):
xbmcgui.Dialog().notification("Beliebte Serien", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Beliebte Serien", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
try: try:
@@ -2606,7 +2604,7 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None:
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("Beliebte Titel werden geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in page_items: for title in page_items:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -2667,13 +2665,13 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None:
plugin_name = (plugin_name or "").strip() plugin_name = (plugin_name or "").strip()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None or not _plugin_has_capability(plugin, "new_titles"): if plugin is None or not _plugin_has_capability(plugin, "new_titles"):
xbmcgui.Dialog().notification("Neue Titel", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Neue Titel", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
getter = getattr(plugin, "new_titles", None) getter = getattr(plugin, "new_titles", None)
if not callable(getter): if not callable(getter):
xbmcgui.Dialog().notification("Neue Titel", "Nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Neue Titel", "Diese Liste ist nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -2715,7 +2713,7 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None:
if total == 0: if total == 0:
xbmcgui.Dialog().notification( xbmcgui.Dialog().notification(
"Neue Titel", "Neue Titel",
"Keine Titel gefunden (Basis-URL/Index prüfen).", "Keine Titel gefunden. Bitte Basis-URL oder Index pruefen.",
xbmcgui.NOTIFICATION_INFO, xbmcgui.NOTIFICATION_INFO,
4000, 4000,
) )
@@ -2754,7 +2752,7 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None:
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("Neue Titel werden geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in page_items: for title in page_items:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -2806,13 +2804,13 @@ def _show_latest_episodes(plugin_name: str, page: int = 1) -> None:
plugin_name = (plugin_name or "").strip() plugin_name = (plugin_name or "").strip()
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if not plugin: if not plugin:
xbmcgui.Dialog().notification("Neueste Folgen", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Neueste Folgen", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
getter = getattr(plugin, "latest_episodes", None) getter = getattr(plugin, "latest_episodes", None)
if not callable(getter): if not callable(getter):
xbmcgui.Dialog().notification("Neueste Folgen", "Nicht unterstützt.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Neueste Folgen", "Diese Quelle bietet das nicht an.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -2820,7 +2818,7 @@ def _show_latest_episodes(plugin_name: str, page: int = 1) -> None:
_set_content(handle, "episodes") _set_content(handle, "episodes")
try: try:
with _busy_dialog(): with _busy_dialog("Neueste Episoden werden geladen..."):
entries = list(getter(page) or []) entries = list(getter(page) or [])
except Exception as exc: except Exception as exc:
_log(f"Neueste Folgen fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) _log(f"Neueste Folgen fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
@@ -2883,7 +2881,7 @@ def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page
page = max(1, int(page or 1)) page = max(1, int(page or 1))
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Genres", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Genres", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
@@ -2925,7 +2923,7 @@ def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("Genre-Gruppe wird geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in page_items: for title in page_items:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -3013,7 +3011,7 @@ def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page
if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art): if _needs_tmdb(meta_labels, meta_art, want_plot=show_plot, want_art=show_art):
tmdb_titles.append(title) tmdb_titles.append(title)
if show_tmdb and tmdb_titles: if show_tmdb and tmdb_titles:
with _busy_dialog(): with _busy_dialog("Genre-Serien werden geladen..."):
tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles)
for title in page_items: for title in page_items:
tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, [])
@@ -3071,11 +3069,11 @@ def _run_update_check() -> None:
builtin("UpdateAddonRepos") builtin("UpdateAddonRepos")
builtin("UpdateLocalAddons") builtin("UpdateLocalAddons")
builtin("ActivateWindow(addonbrowser,addons://updates/)") builtin("ActivateWindow(addonbrowser,addons://updates/)")
xbmcgui.Dialog().notification("ViewIT Update", "Update-Pruefung gestartet.", xbmcgui.NOTIFICATION_INFO, 4000) xbmcgui.Dialog().notification("Updates", "Update-Check gestartet.", xbmcgui.NOTIFICATION_INFO, 4000)
except Exception as exc: except Exception as exc:
_log(f"Update-Pruefung fehlgeschlagen: {exc}", xbmc.LOGWARNING) _log(f"Update-Pruefung fehlgeschlagen: {exc}", xbmc.LOGWARNING)
try: try:
xbmcgui.Dialog().notification("ViewIT Update", "Update-Pruefung fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4000) xbmcgui.Dialog().notification("Updates", "Update-Check fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4000)
except Exception: except Exception:
pass pass
@@ -3249,14 +3247,14 @@ def _play_episode(
_log(f"Play anfordern: {plugin_name} / {title} / {season} / {episode}") _log(f"Play anfordern: {plugin_name} / {title} / {season} / {episode}")
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Play", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Wiedergabe", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
return return
available_hosters: list[str] = [] available_hosters: list[str] = []
hoster_getter = getattr(plugin, "available_hosters_for", None) hoster_getter = getattr(plugin, "available_hosters_for", None)
if callable(hoster_getter): if callable(hoster_getter):
try: try:
with _busy_dialog(): with _busy_dialog("Hoster werden geladen..."):
available_hosters = list(hoster_getter(title, season, episode) or []) available_hosters = list(hoster_getter(title, season, episode) or [])
except Exception as exc: except Exception as exc:
_log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) _log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
@@ -3266,7 +3264,7 @@ def _play_episode(
if len(available_hosters) == 1: if len(available_hosters) == 1:
selected_hoster = available_hosters[0] selected_hoster = available_hosters[0]
else: else:
selected_index = xbmcgui.Dialog().select("Hoster wählen", 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)
return return
@@ -3284,8 +3282,8 @@ def _play_episode(
try: try:
link = plugin.stream_link_for(title, season, episode) link = plugin.stream_link_for(title, season, episode)
if not link: if not link:
_log("Kein Stream-Link gefunden.", xbmc.LOGWARNING) _log("Kein Stream gefunden.", xbmc.LOGWARNING)
xbmcgui.Dialog().notification("Play", "Kein Stream-Link gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
return return
_log(f"Stream-Link: {link}", xbmc.LOGDEBUG) _log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
final_link = plugin.resolve_stream_link(link) or link final_link = plugin.resolve_stream_link(link) or link
@@ -3333,14 +3331,14 @@ def _play_episode_url(
_log(f"Play (URL) anfordern: {plugin_name} / {title} / {season_label} / {episode_label} / {episode_url}") _log(f"Play (URL) anfordern: {plugin_name} / {title} / {season_label} / {episode_label} / {episode_url}")
plugin = _discover_plugins().get(plugin_name) plugin = _discover_plugins().get(plugin_name)
if plugin is None: if plugin is None:
xbmcgui.Dialog().notification("Play", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Wiedergabe", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
return return
available_hosters: list[str] = [] available_hosters: list[str] = []
hoster_getter = getattr(plugin, "available_hosters_for_url", None) hoster_getter = getattr(plugin, "available_hosters_for_url", None)
if callable(hoster_getter): if callable(hoster_getter):
try: try:
with _busy_dialog(): with _busy_dialog("Hoster werden geladen..."):
available_hosters = list(hoster_getter(episode_url) or []) available_hosters = list(hoster_getter(episode_url) or [])
except Exception as exc: except Exception as exc:
_log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) _log(f"Hoster laden fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
@@ -3350,7 +3348,7 @@ def _play_episode_url(
if len(available_hosters) == 1: if len(available_hosters) == 1:
selected_hoster = available_hosters[0] selected_hoster = available_hosters[0]
else: else:
selected_index = xbmcgui.Dialog().select("Hoster wählen", 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)
return return
@@ -3367,12 +3365,12 @@ def _play_episode_url(
try: try:
link_getter = getattr(plugin, "stream_link_for_url", None) link_getter = getattr(plugin, "stream_link_for_url", None)
if not callable(link_getter): if not callable(link_getter):
xbmcgui.Dialog().notification("Play", "Nicht unterstützt.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Wiedergabe", "Diese Funktion wird von der Quelle nicht unterstuetzt.", xbmcgui.NOTIFICATION_INFO, 3000)
return return
link = link_getter(episode_url) link = link_getter(episode_url)
if not link: if not link:
_log("Kein Stream-Link gefunden.", xbmc.LOGWARNING) _log("Kein Stream gefunden.", xbmc.LOGWARNING)
xbmcgui.Dialog().notification("Play", "Kein Stream-Link gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000)
return return
_log(f"Stream-Link: {link}", xbmc.LOGDEBUG) _log(f"Stream-Link: {link}", xbmc.LOGDEBUG)
final_link = plugin.resolve_stream_link(link) or link final_link = plugin.resolve_stream_link(link) or link

View File

@@ -32,3 +32,12 @@ def get_requests_session(key: str, *, headers: Optional[dict[str, str]] = None):
pass pass
return session return session
def close_all_sessions() -> None:
"""Close and clear all pooled sessions."""
for session in list(_SESSIONS.values()):
try:
session.close()
except Exception:
pass
_SESSIONS.clear()

93
addon/metadata_utils.py Normal file
View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import re
from plugin_interface import BasisPlugin
from tmdb import TmdbCastMember
METADATA_MODE_AUTO = 0
METADATA_MODE_SOURCE = 1
METADATA_MODE_TMDB = 2
METADATA_MODE_MIX = 3
def metadata_setting_id(plugin_name: str) -> str:
safe = re.sub(r"[^a-z0-9]+", "_", (plugin_name or "").strip().casefold()).strip("_")
return f"{safe}_metadata_source" if safe else "metadata_source"
def plugin_supports_metadata(plugin: BasisPlugin) -> bool:
try:
return plugin.__class__.metadata_for is not BasisPlugin.metadata_for
except Exception:
return False
def metadata_policy(
plugin_name: str,
plugin: BasisPlugin,
*,
allow_tmdb: bool,
get_setting_int=None,
) -> tuple[bool, bool, bool]:
if not callable(get_setting_int):
return plugin_supports_metadata(plugin), allow_tmdb, bool(getattr(plugin, "prefer_source_metadata", False))
mode = get_setting_int(metadata_setting_id(plugin_name), default=METADATA_MODE_AUTO)
supports_source = plugin_supports_metadata(plugin)
if mode == METADATA_MODE_SOURCE:
return supports_source, False, True
if mode == METADATA_MODE_TMDB:
return False, allow_tmdb, False
if mode == METADATA_MODE_MIX:
return supports_source, allow_tmdb, True
prefer_source = bool(getattr(plugin, "prefer_source_metadata", False))
return supports_source, allow_tmdb, prefer_source
def collect_plugin_metadata(
plugin: BasisPlugin,
titles: list[str],
) -> dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None]]:
getter = getattr(plugin, "metadata_for", None)
if not callable(getter):
return {}
collected: dict[str, tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None]] = {}
for title in titles:
try:
labels, art, cast = getter(title)
except Exception:
continue
if isinstance(labels, dict) or isinstance(art, dict) or cast:
label_map = {str(k): str(v) for k, v in dict(labels or {}).items() if v}
art_map = {str(k): str(v) for k, v in dict(art or {}).items() if v}
collected[title] = (label_map, art_map, cast if isinstance(cast, list) else None)
return collected
def needs_tmdb(labels: dict[str, str], art: dict[str, str], *, want_plot: bool, want_art: bool) -> bool:
if want_plot and not labels.get("plot"):
return True
if want_art and not (art.get("thumb") or art.get("poster") or art.get("fanart") or art.get("landscape")):
return True
return False
def merge_metadata(
title: str,
tmdb_labels: dict[str, str] | None,
tmdb_art: dict[str, str] | None,
tmdb_cast: list[TmdbCastMember] | None,
plugin_meta: tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None] | None,
) -> tuple[dict[str, str], dict[str, str], list[TmdbCastMember] | None]:
labels = dict(tmdb_labels or {})
art = dict(tmdb_art or {})
cast = tmdb_cast
if plugin_meta is not None:
meta_labels, meta_art, meta_cast = plugin_meta
labels.update({k: str(v) for k, v in dict(meta_labels or {}).items() if v})
art.update({k: str(v) for k, v in dict(meta_art or {}).items() if v})
if meta_cast is not None:
cast = meta_cast
if "title" not in labels:
labels["title"] = title
return labels, art, cast

View File

@@ -4,7 +4,7 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Set, Tuple from typing import Any, Callable, Dict, List, Optional, Set, Tuple
class BasisPlugin(ABC): class BasisPlugin(ABC):
@@ -15,7 +15,11 @@ class BasisPlugin(ABC):
prefer_source_metadata: bool = False prefer_source_metadata: bool = False
@abstractmethod @abstractmethod
async def search_titles(self, query: str) -> List[str]: async def search_titles(
self,
query: str,
progress_callback: Optional[Callable[[str, Optional[int]], Any]] = None,
) -> List[str]:
"""Liefert eine Liste aller Treffer fuer die Suche.""" """Liefert eine Liste aller Treffer fuer die Suche."""
@abstractmethod @abstractmethod

View File

@@ -9,7 +9,7 @@ Zum Verwenden:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional from typing import TYPE_CHECKING, Any, Callable, List, Optional
try: # pragma: no cover - optional dependency try: # pragma: no cover - optional dependency
import requests import requests
@@ -88,9 +88,13 @@ class TemplatePlugin(BasisPlugin):
self._session = session self._session = session
return self._session return self._session
async def search_titles(self, query: str) -> List[str]: async def search_titles(
self,
query: str,
progress_callback: Optional[Callable[[str, Optional[int]], Any]] = None,
) -> List[str]:
"""TODO: Suche auf der Zielseite implementieren.""" """TODO: Suche auf der Zielseite implementieren."""
_ = query _ = (query, progress_callback)
return [] return []
def seasons_for(self, title: str) -> List[str]: def seasons_for(self, title: str) -> List[str]:

View File

@@ -13,7 +13,8 @@ import hashlib
import json import json
import re import re
import time import time
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import quote
try: # pragma: no cover - optional dependency try: # pragma: no cover - optional dependency
import requests import requests
@@ -69,6 +70,16 @@ HEADERS = {
SESSION_CACHE_TTL_SECONDS = 300 SESSION_CACHE_TTL_SECONDS = 300
SESSION_CACHE_PREFIX = "viewit.aniworld" SESSION_CACHE_PREFIX = "viewit.aniworld"
SESSION_CACHE_MAX_TITLE_URLS = 800 SESSION_CACHE_MAX_TITLE_URLS = 800
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
def _emit_progress(callback: ProgressCallback, message: str, percent: Optional[int] = None) -> None:
if not callable(callback):
return
try:
callback(str(message or ""), None if percent is None else int(percent))
except Exception:
return
@dataclass @dataclass
@@ -126,7 +137,7 @@ def _latest_episodes_url() -> str:
def _search_url(query: str) -> str: def _search_url(query: str) -> str:
return f"{_get_base_url()}/search?q={query}" return f"{_get_base_url()}/search?q={quote((query or '').strip())}"
def _search_api_url() -> str: def _search_api_url() -> str:
@@ -289,37 +300,56 @@ def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> Beautif
_ensure_requests() _ensure_requests()
_log_visit(url) _log_visit(url)
sess = session or get_requests_session("aniworld", headers=HEADERS) sess = session or get_requests_session("aniworld", headers=HEADERS)
response = None
try: try:
response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
_log_error(f"GET {url} failed: {exc}") _log_error(f"GET {url} failed: {exc}")
raise raise
if response.url and response.url != url: try:
_log_url(response.url, kind="REDIRECT") final_url = (response.url or url) if response is not None else url
_log_response_html(url, response.text) body = (response.text or "") if response is not None else ""
if _looks_like_cloudflare_challenge(response.text): if final_url != url:
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.") _log_url(final_url, kind="REDIRECT")
return BeautifulSoup(response.text, "html.parser") _log_response_html(url, body)
if _looks_like_cloudflare_challenge(body):
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
return BeautifulSoup(body, "html.parser")
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _get_html_simple(url: str) -> str: def _get_html_simple(url: str) -> str:
_ensure_requests() _ensure_requests()
_log_visit(url) _log_visit(url)
sess = get_requests_session("aniworld", headers=HEADERS) sess = get_requests_session("aniworld", headers=HEADERS)
response = None
try: try:
response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
_log_error(f"GET {url} failed: {exc}") _log_error(f"GET {url} failed: {exc}")
raise raise
if response.url and response.url != url: try:
_log_url(response.url, kind="REDIRECT") final_url = (response.url or url) if response is not None else url
body = response.text body = (response.text or "") if response is not None else ""
_log_response_html(url, body) if final_url != url:
if _looks_like_cloudflare_challenge(body): _log_url(final_url, kind="REDIRECT")
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.") _log_response_html(url, body)
return body if _looks_like_cloudflare_challenge(body):
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
return body
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _get_soup_simple(url: str) -> BeautifulSoupT: def _get_soup_simple(url: str) -> BeautifulSoupT:
@@ -351,17 +381,27 @@ def _post_json(url: str, *, payload: Dict[str, str], session: Optional[RequestsS
_ensure_requests() _ensure_requests()
_log_visit(url) _log_visit(url)
sess = session or get_requests_session("aniworld", headers=HEADERS) sess = session or get_requests_session("aniworld", headers=HEADERS)
response = sess.post(url, data=payload, headers=HEADERS, timeout=DEFAULT_TIMEOUT) response = None
response.raise_for_status()
if response.url and response.url != url:
_log_url(response.url, kind="REDIRECT")
_log_response_html(url, response.text)
if _looks_like_cloudflare_challenge(response.text):
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
try: try:
return response.json() response = sess.post(url, data=payload, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
except Exception: response.raise_for_status()
return None final_url = (response.url or url) if response is not None else url
body = (response.text or "") if response is not None else ""
if final_url != url:
_log_url(final_url, kind="REDIRECT")
_log_response_html(url, body)
if _looks_like_cloudflare_challenge(body):
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
try:
return response.json()
except Exception:
return None
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _extract_canonical_url(soup: BeautifulSoupT, fallback: str) -> str: def _extract_canonical_url(soup: BeautifulSoupT, fallback: str) -> str:
@@ -555,10 +595,18 @@ def resolve_redirect(target_url: str) -> Optional[str]:
_log_visit(normalized_url) _log_visit(normalized_url)
session = get_requests_session("aniworld", headers=HEADERS) session = get_requests_session("aniworld", headers=HEADERS)
_get_soup(_get_base_url(), session=session) _get_soup(_get_base_url(), session=session)
response = session.get(normalized_url, headers=HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True) response = None
if response.url: try:
_log_url(response.url, kind="RESOLVED") response = session.get(normalized_url, headers=HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
return response.url if response.url else None if response.url:
_log_url(response.url, kind="RESOLVED")
return response.url if response.url else None
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def fetch_episode_hoster_names(episode_url: str) -> List[str]: def fetch_episode_hoster_names(episode_url: str) -> List[str]:
@@ -629,11 +677,12 @@ def fetch_episode_stream_link(
return resolved return resolved
def search_animes(query: str) -> List[SeriesResult]: def search_animes(query: str, *, progress_callback: ProgressCallback = None) -> List[SeriesResult]:
_ensure_requests() _ensure_requests()
query = (query or "").strip() query = (query or "").strip()
if not query: if not query:
return [] return []
_emit_progress(progress_callback, "AniWorld API-Suche", 15)
session = get_requests_session("aniworld", headers=HEADERS) session = get_requests_session("aniworld", headers=HEADERS)
try: try:
session.get(_get_base_url(), headers=HEADERS, timeout=DEFAULT_TIMEOUT) session.get(_get_base_url(), headers=HEADERS, timeout=DEFAULT_TIMEOUT)
@@ -643,7 +692,9 @@ def search_animes(query: str) -> List[SeriesResult]:
results: List[SeriesResult] = [] results: List[SeriesResult] = []
seen: set[str] = set() seen: set[str] = set()
if isinstance(data, list): if isinstance(data, list):
for entry in data: for idx, entry in enumerate(data, start=1):
if idx == 1 or idx % 50 == 0:
_emit_progress(progress_callback, f"API auswerten {idx}/{len(data)}", 35)
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
title = _strip_html((entry.get("title") or "").strip()) title = _strip_html((entry.get("title") or "").strip())
@@ -665,10 +716,16 @@ def search_animes(query: str) -> List[SeriesResult]:
seen.add(key) seen.add(key)
description = (entry.get("description") or "").strip() description = (entry.get("description") or "").strip()
results.append(SeriesResult(title=title, description=description, url=url)) results.append(SeriesResult(title=title, description=description, url=url))
_emit_progress(progress_callback, f"API-Treffer: {len(results)}", 85)
return results return results
soup = _get_soup_simple(_search_url(requests.utils.quote(query))) _emit_progress(progress_callback, "HTML-Suche (Fallback)", 55)
for anchor in soup.select("a[href^='/anime/stream/'][href]"): soup = _get_soup_simple(_search_url(query))
anchors = soup.select("a[href^='/anime/stream/'][href]")
total_anchors = max(1, len(anchors))
for idx, anchor in enumerate(anchors, start=1):
if idx == 1 or idx % 100 == 0:
_emit_progress(progress_callback, f"HTML auswerten {idx}/{total_anchors}", 70)
href = (anchor.get("href") or "").strip() href = (anchor.get("href") or "").strip()
if not href or "/staffel-" in href or "/episode-" in href: if not href or "/staffel-" in href or "/episode-" in href:
continue continue
@@ -686,6 +743,7 @@ def search_animes(query: str) -> List[SeriesResult]:
continue continue
seen.add(key) seen.add(key)
results.append(SeriesResult(title=title, description="", url=url)) results.append(SeriesResult(title=title, description="", url=url))
_emit_progress(progress_callback, f"HTML-Treffer: {len(results)}", 85)
return results return results
@@ -1151,7 +1209,7 @@ class AniworldPlugin(BasisPlugin):
return self._episode_label_cache.get(cache_key, {}).get(episode_label) return self._episode_label_cache.get(cache_key, {}).get(episode_label)
return None return None
async def search_titles(self, query: str) -> List[str]: async def search_titles(self, query: str, progress_callback: ProgressCallback = None) -> List[str]:
query = (query or "").strip() query = (query or "").strip()
if not query: if not query:
self._anime_results.clear() self._anime_results.clear()
@@ -1163,7 +1221,8 @@ class AniworldPlugin(BasisPlugin):
if not self._requests_available: if not self._requests_available:
raise RuntimeError("AniworldPlugin kann ohne requests/bs4 nicht suchen.") raise RuntimeError("AniworldPlugin kann ohne requests/bs4 nicht suchen.")
try: try:
results = search_animes(query) _emit_progress(progress_callback, "AniWorld Suche startet", 10)
results = search_animes(query, progress_callback=progress_callback)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
self._anime_results.clear() self._anime_results.clear()
self._season_cache.clear() self._season_cache.clear()
@@ -1178,6 +1237,7 @@ class AniworldPlugin(BasisPlugin):
self._season_cache.clear() self._season_cache.clear()
self._season_links_cache.clear() self._season_links_cache.clear()
self._episode_label_cache.clear() self._episode_label_cache.clear()
_emit_progress(progress_callback, f"Treffer aufbereitet: {len(results)}", 95)
return [result.title for result in results] return [result.title for result in results]
def _ensure_seasons(self, title: str) -> List[SeasonInfo]: def _ensure_seasons(self, title: str) -> List[SeasonInfo]:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import re import re
from urllib.parse import quote from urllib.parse import quote
from typing import TYPE_CHECKING, Any, Dict, List, Optional from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
try: # pragma: no cover - optional dependency try: # pragma: no cover - optional dependency
import requests import requests
@@ -44,6 +44,16 @@ SETTING_LOG_URLS = "log_urls_dokustreams"
SETTING_DUMP_HTML = "dump_html_dokustreams" SETTING_DUMP_HTML = "dump_html_dokustreams"
SETTING_SHOW_URL_INFO = "show_url_info_dokustreams" SETTING_SHOW_URL_INFO = "show_url_info_dokustreams"
SETTING_LOG_ERRORS = "log_errors_dokustreams" SETTING_LOG_ERRORS = "log_errors_dokustreams"
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
def _emit_progress(callback: ProgressCallback, message: str, percent: Optional[int] = None) -> None:
if not callable(callback):
return
try:
callback(str(message or ""), None if percent is None else int(percent))
except Exception:
return
HEADERS = { HEADERS = {
"User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)", "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
@@ -213,16 +223,26 @@ def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> Beautif
raise RuntimeError("requests/bs4 sind nicht verfuegbar.") raise RuntimeError("requests/bs4 sind nicht verfuegbar.")
_log_visit(url) _log_visit(url)
sess = session or get_requests_session("dokustreams", headers=HEADERS) sess = session or get_requests_session("dokustreams", headers=HEADERS)
response = None
try: try:
response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
_log_error_message(f"GET {url} failed: {exc}") _log_error_message(f"GET {url} failed: {exc}")
raise raise
if response.url and response.url != url: try:
_log_url_event(response.url, kind="REDIRECT") final_url = (response.url or url) if response is not None else url
_log_response_html(url, response.text) body = (response.text or "") if response is not None else ""
return BeautifulSoup(response.text, "html.parser") if final_url != url:
_log_url_event(final_url, kind="REDIRECT")
_log_response_html(url, body)
return BeautifulSoup(body, "html.parser")
finally:
if response is not None:
try:
response.close()
except Exception:
pass
class DokuStreamsPlugin(BasisPlugin): class DokuStreamsPlugin(BasisPlugin):
@@ -247,14 +267,17 @@ class DokuStreamsPlugin(BasisPlugin):
if REQUESTS_IMPORT_ERROR: if REQUESTS_IMPORT_ERROR:
print(f"DokuStreamsPlugin Importfehler: {REQUESTS_IMPORT_ERROR}") print(f"DokuStreamsPlugin Importfehler: {REQUESTS_IMPORT_ERROR}")
async def search_titles(self, query: str) -> List[str]: async def search_titles(self, query: str, progress_callback: ProgressCallback = None) -> List[str]:
_emit_progress(progress_callback, "Doku-Streams Suche", 15)
hits = self._search_hits(query) hits = self._search_hits(query)
_emit_progress(progress_callback, f"Treffer verarbeiten ({len(hits)})", 70)
self._title_to_url = {hit.title: hit.url for hit in hits if hit.title and hit.url} self._title_to_url = {hit.title: hit.url for hit in hits if hit.title and hit.url}
for hit in hits: for hit in hits:
if hit.title: if hit.title:
self._title_meta[hit.title] = (hit.plot, hit.poster) self._title_meta[hit.title] = (hit.plot, hit.poster)
titles = [hit.title for hit in hits if hit.title] titles = [hit.title for hit in hits if hit.title]
titles.sort(key=lambda value: value.casefold()) titles.sort(key=lambda value: value.casefold())
_emit_progress(progress_callback, f"Fertig: {len(titles)} Treffer", 95)
return titles return titles
def _search_hits(self, query: str) -> List[SearchHit]: def _search_hits(self, query: str) -> List[SearchHit]:

View File

@@ -11,7 +11,7 @@ from __future__ import annotations
import json import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Set from typing import Any, Callable, Dict, List, Optional, Set
from urllib.parse import urlencode, urljoin, urlsplit from urllib.parse import urlencode, urljoin, urlsplit
try: # pragma: no cover - optional dependency (Kodi dependency) try: # pragma: no cover - optional dependency (Kodi dependency)
@@ -56,6 +56,16 @@ HEADERS = {
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8", "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
"Connection": "keep-alive", "Connection": "keep-alive",
} }
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
def _emit_progress(callback: ProgressCallback, message: str, percent: Optional[int] = None) -> None:
if not callable(callback):
return
try:
callback(str(message or ""), None if percent is None else int(percent))
except Exception:
return
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -526,6 +536,34 @@ class EinschaltenPlugin(BasisPlugin):
self._session = requests.Session() self._session = requests.Session()
return self._session return self._session
def _http_get_text(self, url: str, *, timeout: int = 20) -> tuple[str, str]:
_log_url(url, kind="GET")
_notify_url(url)
sess = self._get_session()
response = None
try:
response = sess.get(url, headers=HEADERS, timeout=timeout)
response.raise_for_status()
final_url = (response.url or url) if response is not None else url
body = (response.text or "") if response is not None else ""
_log_url(final_url, kind="OK")
_log_response_html(final_url, body)
return final_url, body
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _http_get_json(self, url: str, *, timeout: int = 20) -> tuple[str, Any]:
final_url, body = self._http_get_text(url, timeout=timeout)
try:
payload = json.loads(body or "{}")
except Exception:
payload = {}
return final_url, payload
def _get_base_url(self) -> str: def _get_base_url(self) -> str:
base = _get_setting_text(SETTING_BASE_URL, default=DEFAULT_BASE_URL).strip() base = _get_setting_text(SETTING_BASE_URL, default=DEFAULT_BASE_URL).strip()
return base.rstrip("/") return base.rstrip("/")
@@ -646,15 +684,9 @@ class EinschaltenPlugin(BasisPlugin):
if not url: if not url:
return "" return ""
try: try:
_log_url(url, kind="GET") _, body = self._http_get_text(url, timeout=20)
_notify_url(url) self._detail_html_by_id[movie_id] = body
sess = self._get_session() return body
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
_log_response_html(resp.url or url, resp.text)
self._detail_html_by_id[movie_id] = resp.text or ""
return resp.text or ""
except Exception as exc: except Exception as exc:
_log_error(f"GET {url} failed: {exc}") _log_error(f"GET {url} failed: {exc}")
return "" return ""
@@ -667,16 +699,8 @@ class EinschaltenPlugin(BasisPlugin):
if not url: if not url:
return {} return {}
try: try:
_log_url(url, kind="GET") _, data = self._http_get_json(url, timeout=20)
_notify_url(url) return data
sess = self._get_session()
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
# Some backends may return JSON with a JSON content-type; for debugging we still dump text.
_log_response_html(resp.url or url, resp.text)
data = resp.json()
return dict(data) if isinstance(data, dict) else {}
except Exception as exc: except Exception as exc:
_log_error(f"GET {url} failed: {exc}") _log_error(f"GET {url} failed: {exc}")
return {} return {}
@@ -741,14 +765,8 @@ class EinschaltenPlugin(BasisPlugin):
if not url: if not url:
return [] return []
try: try:
_log_url(url, kind="GET") _, body = self._http_get_text(url, timeout=20)
_notify_url(url) payload = _extract_ng_state_payload(body)
sess = self._get_session()
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
_log_response_html(resp.url or url, resp.text)
payload = _extract_ng_state_payload(resp.text)
return _parse_ng_state_movies(payload) return _parse_ng_state_movies(payload)
except Exception: except Exception:
return [] return []
@@ -759,14 +777,8 @@ class EinschaltenPlugin(BasisPlugin):
if not url: if not url:
return [] return []
try: try:
_log_url(url, kind="GET") _, body = self._http_get_text(url, timeout=20)
_notify_url(url) payload = _extract_ng_state_payload(body)
sess = self._get_session()
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
_log_response_html(resp.url or url, resp.text)
payload = _extract_ng_state_payload(resp.text)
movies = _parse_ng_state_movies(payload) movies = _parse_ng_state_movies(payload)
_log_debug_line(f"parse_ng_state_movies:count={len(movies)}") _log_debug_line(f"parse_ng_state_movies:count={len(movies)}")
if movies: if movies:
@@ -784,14 +796,8 @@ class EinschaltenPlugin(BasisPlugin):
if page > 1: if page > 1:
url = f"{url}?{urlencode({'page': str(page)})}" url = f"{url}?{urlencode({'page': str(page)})}"
try: try:
_log_url(url, kind="GET") _, body = self._http_get_text(url, timeout=20)
_notify_url(url) payload = _extract_ng_state_payload(body)
sess = self._get_session()
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
_log_response_html(resp.url or url, resp.text)
payload = _extract_ng_state_payload(resp.text)
movies, has_more, current_page = _parse_ng_state_movies_with_pagination(payload) movies, has_more, current_page = _parse_ng_state_movies_with_pagination(payload)
_log_debug_line(f"parse_ng_state_movies_page:page={page} count={len(movies)}") _log_debug_line(f"parse_ng_state_movies_page:page={page} count={len(movies)}")
if has_more is not None: if has_more is not None:
@@ -844,14 +850,8 @@ class EinschaltenPlugin(BasisPlugin):
if not url: if not url:
return [] return []
try: try:
_log_url(url, kind="GET") _, body = self._http_get_text(url, timeout=20)
_notify_url(url) payload = _extract_ng_state_payload(body)
sess = self._get_session()
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
_log_response_html(resp.url or url, resp.text)
payload = _extract_ng_state_payload(resp.text)
results = _parse_ng_state_search_results(payload) results = _parse_ng_state_search_results(payload)
return _filter_movies_by_title(query, results) return _filter_movies_by_title(query, results)
except Exception: except Exception:
@@ -867,13 +867,7 @@ class EinschaltenPlugin(BasisPlugin):
api_url = self._api_genres_url() api_url = self._api_genres_url()
if api_url: if api_url:
try: try:
_log_url(api_url, kind="GET") _, payload = self._http_get_json(api_url, timeout=20)
_notify_url(api_url)
sess = self._get_session()
resp = sess.get(api_url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or api_url, kind="OK")
payload = resp.json()
if isinstance(payload, list): if isinstance(payload, list):
parsed: Dict[str, int] = {} parsed: Dict[str, int] = {}
for item in payload: for item in payload:
@@ -900,14 +894,8 @@ class EinschaltenPlugin(BasisPlugin):
if not url: if not url:
return return
try: try:
_log_url(url, kind="GET") _, body = self._http_get_text(url, timeout=20)
_notify_url(url) payload = _extract_ng_state_payload(body)
sess = self._get_session()
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
_log_response_html(resp.url or url, resp.text)
payload = _extract_ng_state_payload(resp.text)
parsed = _parse_ng_state_genres(payload) parsed = _parse_ng_state_genres(payload)
if parsed: if parsed:
self._genre_id_by_name.clear() self._genre_id_by_name.clear()
@@ -915,7 +903,7 @@ class EinschaltenPlugin(BasisPlugin):
except Exception: except Exception:
return return
async def search_titles(self, query: str) -> List[str]: async def search_titles(self, query: str, progress_callback: ProgressCallback = None) -> List[str]:
if not REQUESTS_AVAILABLE: if not REQUESTS_AVAILABLE:
return [] return []
query = (query or "").strip() query = (query or "").strip()
@@ -924,9 +912,12 @@ class EinschaltenPlugin(BasisPlugin):
if not self._get_base_url(): if not self._get_base_url():
return [] return []
_emit_progress(progress_callback, "Einschalten Suche", 15)
movies = self._fetch_search_movies(query) movies = self._fetch_search_movies(query)
if not movies: if not movies:
_emit_progress(progress_callback, "Fallback: Index filtern", 45)
movies = _filter_movies_by_title(query, self._load_movies()) movies = _filter_movies_by_title(query, self._load_movies())
_emit_progress(progress_callback, f"Treffer verarbeiten ({len(movies)})", 75)
titles: List[str] = [] titles: List[str] = []
seen: set[str] = set() seen: set[str] = set()
for movie in movies: for movie in movies:
@@ -936,6 +927,7 @@ class EinschaltenPlugin(BasisPlugin):
self._id_by_title[movie.title] = movie.id self._id_by_title[movie.title] = movie.id
titles.append(movie.title) titles.append(movie.title)
titles.sort(key=lambda value: value.casefold()) titles.sort(key=lambda value: value.casefold())
_emit_progress(progress_callback, f"Fertig: {len(titles)} Treffer", 95)
return titles return titles
def genres(self) -> List[str]: def genres(self) -> List[str]:
@@ -971,14 +963,8 @@ class EinschaltenPlugin(BasisPlugin):
if not url: if not url:
return [] return []
try: try:
_log_url(url, kind="GET") _, body = self._http_get_text(url, timeout=20)
_notify_url(url) payload = _extract_ng_state_payload(body)
sess = self._get_session()
resp = sess.get(url, headers=HEADERS, timeout=20)
resp.raise_for_status()
_log_url(resp.url or url, kind="OK")
_log_response_html(resp.url or url, resp.text)
payload = _extract_ng_state_payload(resp.text)
except Exception: except Exception:
return [] return []
if not isinstance(payload, dict): if not isinstance(payload, dict):

View File

@@ -11,7 +11,7 @@ from dataclasses import dataclass
import re import re
from urllib.parse import quote, urlencode from urllib.parse import quote, urlencode
from urllib.parse import urljoin from urllib.parse import urljoin
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
try: # pragma: no cover - optional dependency try: # pragma: no cover - optional dependency
import requests import requests
@@ -53,6 +53,16 @@ SETTING_LOG_URLS = "log_urls_filmpalast"
SETTING_DUMP_HTML = "dump_html_filmpalast" SETTING_DUMP_HTML = "dump_html_filmpalast"
SETTING_SHOW_URL_INFO = "show_url_info_filmpalast" SETTING_SHOW_URL_INFO = "show_url_info_filmpalast"
SETTING_LOG_ERRORS = "log_errors_filmpalast" SETTING_LOG_ERRORS = "log_errors_filmpalast"
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
def _emit_progress(callback: ProgressCallback, message: str, percent: Optional[int] = None) -> None:
if not callable(callback):
return
try:
callback(str(message or ""), None if percent is None else int(percent))
except Exception:
return
HEADERS = { HEADERS = {
"User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)", "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
@@ -206,16 +216,26 @@ def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> Beautif
raise RuntimeError("requests/bs4 sind nicht verfuegbar.") raise RuntimeError("requests/bs4 sind nicht verfuegbar.")
_log_visit(url) _log_visit(url)
sess = session or get_requests_session("filmpalast", headers=HEADERS) sess = session or get_requests_session("filmpalast", headers=HEADERS)
response = None
try: try:
response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
_log_error_message(f"GET {url} failed: {exc}") _log_error_message(f"GET {url} failed: {exc}")
raise raise
if response.url and response.url != url: try:
_log_url_event(response.url, kind="REDIRECT") final_url = (response.url or url) if response is not None else url
_log_response_html(url, response.text) body = (response.text or "") if response is not None else ""
return BeautifulSoup(response.text, "html.parser") if final_url != url:
_log_url_event(final_url, kind="REDIRECT")
_log_response_html(url, body)
return BeautifulSoup(body, "html.parser")
finally:
if response is not None:
try:
response.close()
except Exception:
pass
class FilmpalastPlugin(BasisPlugin): class FilmpalastPlugin(BasisPlugin):
@@ -352,6 +372,7 @@ class FilmpalastPlugin(BasisPlugin):
seen_titles: set[str] = set() seen_titles: set[str] = set()
seen_urls: set[str] = set() seen_urls: set[str] = set()
for base_url, params in search_requests: for base_url, params in search_requests:
response = None
try: try:
request_url = base_url if not params else f"{base_url}?{urlencode(params)}" request_url = base_url if not params else f"{base_url}?{urlencode(params)}"
_log_url_event(request_url, kind="GET") _log_url_event(request_url, kind="GET")
@@ -365,6 +386,12 @@ class FilmpalastPlugin(BasisPlugin):
except Exception as exc: except Exception as exc:
_log_error_message(f"search request failed ({base_url}): {exc}") _log_error_message(f"search request failed ({base_url}): {exc}")
continue continue
finally:
if response is not None:
try:
response.close()
except Exception:
pass
anchors = soup.select("article.liste h2 a[href], article.liste h3 a[href]") anchors = soup.select("article.liste h2 a[href], article.liste h3 a[href]")
if not anchors: if not anchors:
@@ -466,9 +493,13 @@ class FilmpalastPlugin(BasisPlugin):
titles.sort(key=lambda value: value.casefold()) titles.sort(key=lambda value: value.casefold())
return titles return titles
async def search_titles(self, query: str) -> List[str]: async def search_titles(self, query: str, progress_callback: ProgressCallback = None) -> List[str]:
_emit_progress(progress_callback, "Filmpalast Suche", 15)
hits = self._search_hits(query) hits = self._search_hits(query)
return self._apply_hits_to_title_index(hits) _emit_progress(progress_callback, f"Treffer verarbeiten ({len(hits)})", 70)
titles = self._apply_hits_to_title_index(hits)
_emit_progress(progress_callback, f"Fertig: {len(titles)} Treffer", 95)
return titles
def _parse_genres(self, soup: BeautifulSoupT) -> Dict[str, str]: def _parse_genres(self, soup: BeautifulSoupT) -> Dict[str, str]:
genres: Dict[str, str] = {} genres: Dict[str, str] = {}
@@ -913,6 +944,7 @@ class FilmpalastPlugin(BasisPlugin):
redirected = link redirected = link
if self._requests_available: if self._requests_available:
response = None
try: try:
session = get_requests_session("filmpalast", headers=HEADERS) session = get_requests_session("filmpalast", headers=HEADERS)
response = session.get(link, headers=HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True) response = session.get(link, headers=HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
@@ -920,6 +952,12 @@ class FilmpalastPlugin(BasisPlugin):
redirected = (response.url or link).strip() or link redirected = (response.url or link).strip() or link
except Exception: except Exception:
redirected = link redirected = link
finally:
if response is not None:
try:
response.close()
except Exception:
pass
# 2) Danach optional die Redirect-URL nochmals auflösen. # 2) Danach optional die Redirect-URL nochmals auflösen.
if callable(resolve_with_resolveurl) and redirected and redirected != link: if callable(resolve_with_resolveurl) and redirected and redirected != link:

View File

@@ -17,7 +17,7 @@ import os
import re import re
import time import time
import unicodedata import unicodedata
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
try: # pragma: no cover - optional dependency try: # pragma: no cover - optional dependency
@@ -80,6 +80,16 @@ SESSION_CACHE_MAX_TITLE_URLS = 800
CATALOG_SEARCH_TTL_SECONDS = 600 CATALOG_SEARCH_TTL_SECONDS = 600
CATALOG_SEARCH_CACHE_KEY = "catalog_index" CATALOG_SEARCH_CACHE_KEY = "catalog_index"
_CATALOG_INDEX_MEMORY: tuple[float, List["SeriesResult"]] = (0.0, []) _CATALOG_INDEX_MEMORY: tuple[float, List["SeriesResult"]] = (0.0, [])
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
def _emit_progress(callback: ProgressCallback, message: str, percent: Optional[int] = None) -> None:
if not callable(callback):
return
try:
callback(str(message or ""), None if percent is None else int(percent))
except Exception:
return
@dataclass @dataclass
@@ -398,37 +408,56 @@ def _get_soup(url: str, *, session: Optional[RequestsSession] = None) -> Beautif
_ensure_requests() _ensure_requests()
_log_visit(url) _log_visit(url)
sess = session or get_requests_session("serienstream", headers=HEADERS) sess = session or get_requests_session("serienstream", headers=HEADERS)
response = None
try: try:
response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
_log_error(f"GET {url} failed: {exc}") _log_error(f"GET {url} failed: {exc}")
raise raise
if response.url and response.url != url: try:
_log_url(response.url, kind="REDIRECT") final_url = (response.url or url) if response is not None else url
_log_response_html(url, response.text) body = (response.text or "") if response is not None else ""
if _looks_like_cloudflare_challenge(response.text): if final_url != url:
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.") _log_url(final_url, kind="REDIRECT")
return BeautifulSoup(response.text, "html.parser") _log_response_html(url, body)
if _looks_like_cloudflare_challenge(body):
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
return BeautifulSoup(body, "html.parser")
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _get_html_simple(url: str) -> str: def _get_html_simple(url: str) -> str:
_ensure_requests() _ensure_requests()
_log_visit(url) _log_visit(url)
sess = get_requests_session("serienstream", headers=HEADERS) sess = get_requests_session("serienstream", headers=HEADERS)
response = None
try: try:
response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT) response = sess.get(url, headers=HEADERS, timeout=DEFAULT_TIMEOUT)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
_log_error(f"GET {url} failed: {exc}") _log_error(f"GET {url} failed: {exc}")
raise raise
if response.url and response.url != url: try:
_log_url(response.url, kind="REDIRECT") final_url = (response.url or url) if response is not None else url
body = response.text body = (response.text or "") if response is not None else ""
_log_response_html(url, body) if final_url != url:
if _looks_like_cloudflare_challenge(body): _log_url(final_url, kind="REDIRECT")
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.") _log_response_html(url, body)
return body if _looks_like_cloudflare_challenge(body):
raise RuntimeError("Cloudflare-Schutz erkannt. requests reicht ggf. nicht aus.")
return body
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _get_soup_simple(url: str) -> BeautifulSoupT: def _get_soup_simple(url: str) -> BeautifulSoupT:
@@ -472,6 +501,7 @@ def _search_series_api(query: str) -> List[SeriesResult]:
terms.extend([token for token in query.split() if token]) terms.extend([token for token in query.split() if token])
seen_urls: set[str] = set() seen_urls: set[str] = set()
for term in terms: for term in terms:
response = None
try: try:
response = sess.get( response = sess.get(
f"{_get_base_url()}/api/search/suggest", f"{_get_base_url()}/api/search/suggest",
@@ -486,6 +516,12 @@ def _search_series_api(query: str) -> List[SeriesResult]:
payload = response.json() payload = response.json()
except Exception: except Exception:
continue continue
finally:
if response is not None:
try:
response.close()
except Exception:
pass
shows = payload.get("shows") if isinstance(payload, dict) else None shows = payload.get("shows") if isinstance(payload, dict) else None
if not isinstance(shows, list): if not isinstance(shows, list):
continue continue
@@ -558,7 +594,7 @@ def _search_series_server(query: str) -> List[SeriesResult]:
return [] return []
def _extract_catalog_index_from_html(body: str) -> List[SeriesResult]: def _extract_catalog_index_from_html(body: str, *, progress_callback: ProgressCallback = None) -> List[SeriesResult]:
items: List[SeriesResult] = [] items: List[SeriesResult] = []
if not body: if not body:
return items return items
@@ -569,7 +605,9 @@ def _extract_catalog_index_from_html(body: str) -> List[SeriesResult]:
) )
anchor_re = re.compile(r"<a[^>]+href=[\"']([^\"']+)[\"'][^>]*>(.*?)</a>", re.IGNORECASE | re.DOTALL) anchor_re = re.compile(r"<a[^>]+href=[\"']([^\"']+)[\"'][^>]*>(.*?)</a>", re.IGNORECASE | re.DOTALL)
data_search_re = re.compile(r"data-search=[\"']([^\"']*)[\"']", re.IGNORECASE) data_search_re = re.compile(r"data-search=[\"']([^\"']*)[\"']", re.IGNORECASE)
for match in item_re.finditer(body): for idx, match in enumerate(item_re.finditer(body), start=1):
if idx == 1 or idx % 200 == 0:
_emit_progress(progress_callback, f"Katalog parsen {idx}", 62)
block = match.group(0) block = match.group(0)
inner = match.group(1) or "" inner = match.group(1) or ""
anchor_match = anchor_re.search(inner) anchor_match = anchor_re.search(inner)
@@ -651,26 +689,33 @@ def _store_catalog_index_in_cache(items: List[SeriesResult]) -> None:
_session_cache_set(CATALOG_SEARCH_CACHE_KEY, payload, ttl_seconds=CATALOG_SEARCH_TTL_SECONDS) _session_cache_set(CATALOG_SEARCH_CACHE_KEY, payload, ttl_seconds=CATALOG_SEARCH_TTL_SECONDS)
def search_series(query: str) -> 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 im (/serien)-Katalog nach Titel. Nutzt Cache + Ein-Pass-Filter."""
_ensure_requests() _ensure_requests()
if not _normalize_search_text(query): if not _normalize_search_text(query):
return [] return []
_emit_progress(progress_callback, "Server-Suche", 15)
server_results = _search_series_server(query) server_results = _search_series_server(query)
if server_results: if server_results:
_emit_progress(progress_callback, f"Server-Treffer: {len(server_results)}", 35)
return [entry for entry in server_results if entry.title and _matches_query(query, title=entry.title)] return [entry for entry in server_results if entry.title and _matches_query(query, title=entry.title)]
_emit_progress(progress_callback, "Pruefe Such-Cache", 42)
cached = _load_catalog_index_from_cache() cached = _load_catalog_index_from_cache()
if cached is not None: if cached is not None:
_emit_progress(progress_callback, f"Cache-Treffer: {len(cached)}", 52)
return [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)] return [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)]
_emit_progress(progress_callback, "Lade Katalogseite", 58)
catalog_url = f"{_get_base_url()}/serien?by=genre" catalog_url = f"{_get_base_url()}/serien?by=genre"
body = _get_html_simple(catalog_url) body = _get_html_simple(catalog_url)
items = _extract_catalog_index_from_html(body) items = _extract_catalog_index_from_html(body, progress_callback=progress_callback)
if not items: if not items:
_emit_progress(progress_callback, "Fallback-Parser", 70)
soup = BeautifulSoup(body, "html.parser") soup = BeautifulSoup(body, "html.parser")
items = _catalog_index_from_soup(soup) items = _catalog_index_from_soup(soup)
if items: if items:
_store_catalog_index_in_cache(items) _store_catalog_index_in_cache(items)
_emit_progress(progress_callback, f"Filtere Treffer ({len(items)})", 85)
return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)] return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)]
@@ -989,15 +1034,23 @@ def resolve_redirect(target_url: str) -> Optional[str]:
_get_soup(_get_base_url(), session=session) _get_soup(_get_base_url(), session=session)
except Exception: except Exception:
pass pass
response = session.get( response = None
normalized_url, try:
headers=HEADERS, response = session.get(
timeout=DEFAULT_TIMEOUT, normalized_url,
allow_redirects=True, headers=HEADERS,
) timeout=DEFAULT_TIMEOUT,
if response.url: allow_redirects=True,
_log_url(response.url, kind="RESOLVED") )
return response.url if response.url else None if response.url:
_log_url(response.url, kind="RESOLVED")
return response.url if response.url else None
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def scrape_series_detail( def scrape_series_detail(
@@ -1681,7 +1734,7 @@ class SerienstreamPlugin(BasisPlugin):
return self._episode_label_cache.get(cache_key, {}).get(episode_label) return self._episode_label_cache.get(cache_key, {}).get(episode_label)
return None return None
async def search_titles(self, query: str) -> List[str]: async def search_titles(self, query: str, progress_callback: ProgressCallback = None) -> List[str]:
query = query.strip() query = query.strip()
if not query: if not query:
self._series_results.clear() self._series_results.clear()
@@ -1695,7 +1748,8 @@ class SerienstreamPlugin(BasisPlugin):
try: try:
# Nutzt den Katalog (/serien), der jetzt nach Genres gruppiert ist. # Nutzt den Katalog (/serien), der jetzt nach Genres gruppiert ist.
# Alternativ gäbe es ein Ajax-Endpoint, aber der ist nicht immer zuverlässig erreichbar. # Alternativ gäbe es ein Ajax-Endpoint, aber der ist nicht immer zuverlässig erreichbar.
results = search_series(query) _emit_progress(progress_callback, "Serienstream Suche startet", 10)
results = search_series(query, progress_callback=progress_callback)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
self._series_results.clear() self._series_results.clear()
self._season_cache.clear() self._season_cache.clear()
@@ -1708,6 +1762,7 @@ class SerienstreamPlugin(BasisPlugin):
self._season_cache.clear() self._season_cache.clear()
self._season_links_cache.clear() self._season_links_cache.clear()
self._episode_label_cache.clear() self._episode_label_cache.clear()
_emit_progress(progress_callback, f"Treffer aufbereitet: {len(results)}", 95)
return [result.title for result in results] return [result.title for result in results]
def _ensure_seasons(self, title: str) -> List[SeasonInfo]: def _ensure_seasons(self, title: str) -> List[SeasonInfo]:

View File

@@ -19,7 +19,7 @@ import hashlib
import os import os
import re import re
import json import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
from urllib.parse import urlencode, urljoin from urllib.parse import urlencode, urljoin
try: # pragma: no cover - optional dependency try: # pragma: no cover - optional dependency
@@ -78,6 +78,16 @@ HEADERS = {
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8", "Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
"Connection": "keep-alive", "Connection": "keep-alive",
} }
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
def _emit_progress(callback: ProgressCallback, message: str, percent: Optional[int] = None) -> None:
if not callable(callback):
return
try:
callback(str(message or ""), None if percent is None else int(percent))
except Exception:
return
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -584,15 +594,25 @@ class TopstreamfilmPlugin(BasisPlugin):
session = self._get_session() session = self._get_session()
self._log_url(url, kind="VISIT") self._log_url(url, kind="VISIT")
self._notify_url(url) self._notify_url(url)
response = None
try: try:
response = session.get(url, timeout=DEFAULT_TIMEOUT) response = session.get(url, timeout=DEFAULT_TIMEOUT)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
self._log_error(f"GET {url} failed: {exc}") self._log_error(f"GET {url} failed: {exc}")
raise raise
self._log_url(response.url, kind="OK") try:
self._log_response_html(response.url, response.text) final_url = (response.url or url) if response is not None else url
return BeautifulSoup(response.text, "html.parser") body = (response.text or "") if response is not None else ""
self._log_url(final_url, kind="OK")
self._log_response_html(final_url, body)
return BeautifulSoup(body, "html.parser")
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _get_detail_soup(self, title: str) -> Optional[BeautifulSoupT]: def _get_detail_soup(self, title: str) -> Optional[BeautifulSoupT]:
title = (title or "").strip() title = (title or "").strip()
@@ -814,7 +834,7 @@ class TopstreamfilmPlugin(BasisPlugin):
# Sonst: Serie via Streams-Accordion parsen (falls vorhanden). # Sonst: Serie via Streams-Accordion parsen (falls vorhanden).
self._parse_stream_accordion(soup, title=title) self._parse_stream_accordion(soup, title=title)
async def search_titles(self, query: str) -> List[str]: async def search_titles(self, query: str, progress_callback: ProgressCallback = None) -> List[str]:
"""Sucht Titel ueber eine HTML-Suche. """Sucht Titel ueber eine HTML-Suche.
Erwartetes HTML (Snippet): Erwartetes HTML (Snippet):
@@ -827,6 +847,7 @@ class TopstreamfilmPlugin(BasisPlugin):
query = (query or "").strip() query = (query or "").strip()
if not query: if not query:
return [] return []
_emit_progress(progress_callback, "Topstreamfilm Suche", 15)
session = self._get_session() session = self._get_session()
url = self._get_base_url() + "/" url = self._get_base_url() + "/"
@@ -834,6 +855,7 @@ class TopstreamfilmPlugin(BasisPlugin):
request_url = f"{url}?{urlencode(params)}" request_url = f"{url}?{urlencode(params)}"
self._log_url(request_url, kind="GET") self._log_url(request_url, kind="GET")
self._notify_url(request_url) self._notify_url(request_url)
response = None
try: try:
response = session.get( response = session.get(
url, url,
@@ -844,15 +866,28 @@ class TopstreamfilmPlugin(BasisPlugin):
except Exception as exc: except Exception as exc:
self._log_error(f"GET {request_url} failed: {exc}") self._log_error(f"GET {request_url} failed: {exc}")
raise raise
self._log_url(response.url, kind="OK") try:
self._log_response_html(response.url, response.text) final_url = (response.url or request_url) if response is not None else request_url
body = (response.text or "") if response is not None else ""
self._log_url(final_url, kind="OK")
self._log_response_html(final_url, body)
if BeautifulSoup is None: if BeautifulSoup is None:
return [] return []
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(body, "html.parser")
finally:
if response is not None:
try:
response.close()
except Exception:
pass
hits: List[SearchHit] = [] hits: List[SearchHit] = []
for item in soup.select("li.TPostMv"): items = soup.select("li.TPostMv")
total_items = max(1, len(items))
for idx, item in enumerate(items, start=1):
if idx == 1 or idx % 20 == 0:
_emit_progress(progress_callback, f"Treffer pruefen {idx}/{total_items}", 55)
anchor = item.select_one("a[href]") anchor = item.select_one("a[href]")
if not anchor: if not anchor:
continue continue
@@ -885,6 +920,7 @@ class TopstreamfilmPlugin(BasisPlugin):
self._title_to_url[hit.title] = hit.url self._title_to_url[hit.title] = hit.url
titles.append(hit.title) titles.append(hit.title)
self._save_title_url_cache() self._save_title_url_cache()
_emit_progress(progress_callback, f"Fertig: {len(titles)} Treffer", 95)
return titles return titles
def genres(self) -> List[str]: def genres(self) -> List[str]:

View File

@@ -1,85 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<settings> <settings>
<category label="Logging"> <category label="Debug und Logs">
<setting id="debug_log_urls" type="bool" label="URL-Logging aktivieren (global)" default="false" /> <setting id="debug_log_urls" type="bool" label="URLs mitschreiben (global)" default="false" />
<setting id="debug_dump_html" type="bool" label="HTML-Dumps aktivieren (global)" default="false" /> <setting id="debug_dump_html" type="bool" label="HTML speichern (global)" default="false" />
<setting id="debug_show_url_info" type="bool" label="URL-Info anzeigen (global)" default="false" /> <setting id="debug_show_url_info" type="bool" label="Aktuelle URL anzeigen (global)" default="false" />
<setting id="debug_log_errors" type="bool" label="Fehler-Logging aktivieren (global)" default="false" /> <setting id="debug_log_errors" type="bool" label="Fehler mitschreiben (global)" default="false" />
<setting id="log_max_mb" type="number" label="URL-Log: max. Datei-Größe (MB)" default="5" /> <setting id="log_max_mb" type="number" label="URL-Log: maximale Dateigroesse (MB)" default="5" />
<setting id="log_max_files" type="number" label="URL-Log: max. Rotationen" default="3" /> <setting id="log_max_files" type="number" label="URL-Log: Anzahl alter Dateien" default="3" />
<setting id="dump_max_files" type="number" label="HTML-Dumps: max. Dateien pro Plugin" default="200" /> <setting id="dump_max_files" type="number" label="HTML: maximale Dateien pro Plugin" default="200" />
<setting id="log_urls_serienstream" type="bool" label="Serienstream: URL-Logging" default="false" /> <setting id="log_urls_serienstream" type="bool" label="Serienstream: URLs mitschreiben" default="false" />
<setting id="dump_html_serienstream" type="bool" label="Serienstream: HTML-Dumps" default="false" /> <setting id="dump_html_serienstream" type="bool" label="Serienstream: HTML speichern" default="false" />
<setting id="show_url_info_serienstream" type="bool" label="Serienstream: URL-Info anzeigen" default="false" /> <setting id="show_url_info_serienstream" type="bool" label="Serienstream: Aktuelle URL anzeigen" default="false" />
<setting id="log_errors_serienstream" type="bool" label="Serienstream: Fehler loggen" default="false" /> <setting id="log_errors_serienstream" type="bool" label="Serienstream: Fehler mitschreiben" default="false" />
<setting id="log_urls_aniworld" type="bool" label="Aniworld: URL-Logging" default="false" /> <setting id="log_urls_aniworld" type="bool" label="Aniworld: URLs mitschreiben" default="false" />
<setting id="dump_html_aniworld" type="bool" label="Aniworld: HTML-Dumps" default="false" /> <setting id="dump_html_aniworld" type="bool" label="Aniworld: HTML speichern" default="false" />
<setting id="show_url_info_aniworld" type="bool" label="Aniworld: URL-Info anzeigen" default="false" /> <setting id="show_url_info_aniworld" type="bool" label="Aniworld: Aktuelle URL anzeigen" default="false" />
<setting id="log_errors_aniworld" type="bool" label="Aniworld: Fehler loggen" default="false" /> <setting id="log_errors_aniworld" type="bool" label="Aniworld: Fehler mitschreiben" default="false" />
<setting id="log_urls_topstreamfilm" type="bool" label="Topstreamfilm: URL-Logging" default="false" /> <setting id="log_urls_topstreamfilm" type="bool" label="Topstreamfilm: URLs mitschreiben" default="false" />
<setting id="dump_html_topstreamfilm" type="bool" label="Topstreamfilm: HTML-Dumps" default="false" /> <setting id="dump_html_topstreamfilm" type="bool" label="Topstreamfilm: HTML speichern" default="false" />
<setting id="show_url_info_topstreamfilm" type="bool" label="Topstreamfilm: URL-Info anzeigen" default="false" /> <setting id="show_url_info_topstreamfilm" type="bool" label="Topstreamfilm: Aktuelle URL anzeigen" default="false" />
<setting id="log_errors_topstreamfilm" type="bool" label="Topstreamfilm: Fehler loggen" default="false" /> <setting id="log_errors_topstreamfilm" type="bool" label="Topstreamfilm: Fehler mitschreiben" default="false" />
<setting id="log_urls_einschalten" type="bool" label="Einschalten: URL-Logging" default="false" /> <setting id="log_urls_einschalten" type="bool" label="Einschalten: URLs mitschreiben" default="false" />
<setting id="dump_html_einschalten" type="bool" label="Einschalten: HTML-Dumps" default="false" /> <setting id="dump_html_einschalten" type="bool" label="Einschalten: HTML speichern" default="false" />
<setting id="show_url_info_einschalten" type="bool" label="Einschalten: URL-Info anzeigen" default="false" /> <setting id="show_url_info_einschalten" type="bool" label="Einschalten: Aktuelle URL anzeigen" default="false" />
<setting id="log_errors_einschalten" type="bool" label="Einschalten: Fehler loggen" default="false" /> <setting id="log_errors_einschalten" type="bool" label="Einschalten: Fehler mitschreiben" default="false" />
<setting id="log_urls_filmpalast" type="bool" label="Filmpalast: URL-Logging" default="false" /> <setting id="log_urls_filmpalast" type="bool" label="Filmpalast: URLs mitschreiben" default="false" />
<setting id="dump_html_filmpalast" type="bool" label="Filmpalast: HTML-Dumps" default="false" /> <setting id="dump_html_filmpalast" type="bool" label="Filmpalast: HTML speichern" default="false" />
<setting id="show_url_info_filmpalast" type="bool" label="Filmpalast: URL-Info anzeigen" default="false" /> <setting id="show_url_info_filmpalast" type="bool" label="Filmpalast: Aktuelle URL anzeigen" default="false" />
<setting id="log_errors_filmpalast" type="bool" label="Filmpalast: Fehler loggen" default="false" /> <setting id="log_errors_filmpalast" type="bool" label="Filmpalast: Fehler mitschreiben" default="false" />
</category> </category>
<category label="TopStream"> <category label="TopStream">
<setting id="topstream_base_url" type="text" label="Domain (BASE_URL)" default="https://topstreamfilm.live" /> <setting id="topstream_base_url" type="text" label="Basis-URL" default="https://topstreamfilm.live" />
<setting id="topstreamfilm_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" /> <setting id="topstreamfilm_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
<setting id="topstream_genre_max_pages" type="number" label="Genres: max. Seiten laden (Pagination)" default="20" /> <setting id="topstream_genre_max_pages" type="number" label="Genres: max. Seiten laden" default="20" />
</category> </category>
<category label="SerienStream"> <category label="SerienStream">
<setting id="serienstream_base_url" type="text" label="Domain (BASE_URL)" default="https://s.to" /> <setting id="serienstream_base_url" type="text" label="Basis-URL" default="https://s.to" />
<setting id="serienstream_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" /> <setting id="serienstream_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
</category> </category>
<category label="AniWorld"> <category label="AniWorld">
<setting id="aniworld_base_url" type="text" label="Domain (BASE_URL)" default="https://aniworld.to" /> <setting id="aniworld_base_url" type="text" label="Basis-URL" default="https://aniworld.to" />
<setting id="aniworld_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" /> <setting id="aniworld_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
</category> </category>
<category label="Einschalten"> <category label="Einschalten">
<setting id="einschalten_base_url" type="text" label="Domain (BASE_URL)" default="https://einschalten.in" /> <setting id="einschalten_base_url" type="text" label="Basis-URL" default="https://einschalten.in" />
<setting id="einschalten_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" /> <setting id="einschalten_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
</category> </category>
<category label="Filmpalast"> <category label="Filmpalast">
<setting id="filmpalast_base_url" type="text" label="Domain (BASE_URL)" default="https://filmpalast.to" /> <setting id="filmpalast_base_url" type="text" label="Basis-URL" default="https://filmpalast.to" />
<setting id="filmpalast_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" /> <setting id="filmpalast_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
</category> </category>
<category label="Doku-Streams"> <category label="Doku-Streams">
<setting id="doku_streams_base_url" type="text" label="Domain (BASE_URL)" default="https://doku-streams.com" /> <setting id="doku_streams_base_url" type="text" label="Basis-URL" default="https://doku-streams.com" />
<setting id="doku_streams_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" /> <setting id="doku_streams_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Automatisch|Quelle|TMDB|Mischen" />
</category> </category>
<category label="TMDB"> <category label="TMDB">
<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_api_key" type="text" label="TMDB API Key" default="" /> <setting id="tmdb_api_key" type="text" label="TMDB API Key" default="" />
<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_prefetch_concurrency" type="number" label="TMDB: Parallelität (Prefetch, 1-20)" default="6" /> <setting id="tmdb_prefetch_concurrency" type="number" label="TMDB: gleichzeitige Anfragen (1-20)" default="6" />
<setting id="tmdb_show_plot" type="bool" label="TMDB Plot anzeigen" default="true" /> <setting id="tmdb_show_plot" type="bool" label="TMDB Beschreibung anzeigen" default="true" />
<setting id="tmdb_show_art" type="bool" label="TMDB Poster/Thumb anzeigen" default="true" /> <setting id="tmdb_show_art" type="bool" label="TMDB Poster und Vorschaubild anzeigen" default="true" />
<setting id="tmdb_show_fanart" type="bool" label="TMDB Fanart/Backdrop anzeigen" default="true" /> <setting id="tmdb_show_fanart" type="bool" label="TMDB Fanart/Backdrop anzeigen" default="true" />
<setting id="tmdb_show_rating" type="bool" label="TMDB Rating anzeigen" default="true" /> <setting id="tmdb_show_rating" type="bool" label="TMDB Bewertung anzeigen" default="true" />
<setting id="tmdb_show_votes" type="bool" label="TMDB Vote-Count anzeigen" default="false" /> <setting id="tmdb_show_votes" type="bool" label="TMDB Stimmen anzeigen" default="false" />
<setting id="tmdb_show_cast" type="bool" label="TMDB Cast anzeigen" default="false" /> <setting id="tmdb_show_cast" type="bool" label="TMDB Besetzung anzeigen" default="false" />
<setting id="tmdb_show_episode_cast" type="bool" label="TMDB Besetzung pro Episode anzeigen" default="false" /> <setting id="tmdb_show_episode_cast" type="bool" label="TMDB Besetzung pro Episode anzeigen" default="false" />
<setting id="tmdb_genre_metadata" type="bool" label="TMDB Meta in Genre-Liste anzeigen" default="false" /> <setting id="tmdb_genre_metadata" type="bool" label="TMDB Daten in Genre-Listen anzeigen" default="false" />
<setting id="tmdb_log_requests" type="bool" label="TMDB API Requests loggen" default="false" /> <setting id="tmdb_log_requests" type="bool" label="TMDB API-Anfragen loggen" default="false" />
<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="Update"> <category label="Update">
<setting id="update_repo_url" type="text" label="Update-URL (addons.xml)" default="http://127.0.0.1:8080/repo/addons.xml" /> <setting id="update_repo_url" type="text" label="Update-URL (addons.xml)" default="http://127.0.0.1:8080/repo/addons.xml" />
<setting id="run_update_check" type="action" label="Jetzt auf Updates pruefen" action="RunPlugin(plugin://plugin.video.viewit/?action=check_updates)" option="close" /> <setting id="run_update_check" type="action" label="Jetzt nach Updates suchen" action="RunPlugin(plugin://plugin.video.viewit/?action=check_updates)" option="close" />
<setting id="update_info" type="text" label="Kodi-Repository-Updates werden ueber den Kodi-Update-Mechanismus verarbeitet." default="" enable="false" /> <setting id="update_info" type="text" label="Updates laufen ueber den normalen Kodi-Update-Mechanismus." default="" enable="false" />
<setting id="update_version_addon" type="text" label="ViewIT Addon Version" default="-" enable="false" /> <setting id="update_version_addon" type="text" label="ViewIT Version" default="-" enable="false" />
<setting id="update_version_serienstream" type="text" label="Serienstream Plugin Version" default="-" enable="false" /> <setting id="update_version_serienstream" type="text" label="Serienstream Version" default="-" enable="false" />
<setting id="update_version_aniworld" type="text" label="Aniworld Plugin Version" default="-" enable="false" /> <setting id="update_version_aniworld" type="text" label="Aniworld Version" default="-" enable="false" />
<setting id="update_version_einschalten" type="text" label="Einschalten Plugin Version" default="-" enable="false" /> <setting id="update_version_einschalten" type="text" label="Einschalten Version" default="-" enable="false" />
<setting id="update_version_topstreamfilm" type="text" label="Topstreamfilm Plugin Version" default="-" enable="false" /> <setting id="update_version_topstreamfilm" type="text" label="Topstreamfilm Version" default="-" enable="false" />
<setting id="update_version_filmpalast" type="text" label="Filmpalast Plugin Version" default="-" enable="false" /> <setting id="update_version_filmpalast" type="text" label="Filmpalast Version" default="-" enable="false" />
<setting id="update_version_doku_streams" type="text" label="Doku-Streams Plugin Version" default="-" enable="false" /> <setting id="update_version_doku_streams" type="text" label="Doku-Streams Version" default="-" enable="false" />
</category> </category>
</settings> </settings>

View File

@@ -14,6 +14,7 @@ except ImportError: # pragma: no cover
TMDB_API_BASE = "https://api.themoviedb.org/3" TMDB_API_BASE = "https://api.themoviedb.org/3"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p" TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
MAX_CAST_MEMBERS = 30
_TMDB_THREAD_LOCAL = threading.local() _TMDB_THREAD_LOCAL = threading.local()
@@ -73,53 +74,17 @@ def _fetch_credits(
return [] return []
params = {"api_key": api_key, "language": (language or "de-DE").strip()} params = {"api_key": api_key, "language": (language or "de-DE").strip()}
url = f"{TMDB_API_BASE}/{kind}/{tmdb_id}/credits?{urlencode(params)}" url = f"{TMDB_API_BASE}/{kind}/{tmdb_id}/credits?{urlencode(params)}"
if callable(log): status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
log(f"TMDB GET {url}")
try:
response = requests.get(url, timeout=timeout)
except Exception as exc: # pragma: no cover
if callable(log):
log(f"TMDB ERROR /{kind}/{{id}}/credits request_failed error={exc!r}")
return []
status = getattr(response, "status_code", None)
if callable(log): if callable(log):
log(f"TMDB RESPONSE /{kind}/{{id}}/credits status={status}") log(f"TMDB RESPONSE /{kind}/{{id}}/credits status={status}")
if status != 200: if log_responses and payload is None and body_text:
log(f"TMDB RESPONSE_BODY /{kind}/{{id}}/credits body={body_text[:2000]}")
if status != 200 or not isinstance(payload, dict):
return [] return []
try:
payload = response.json() or {}
except Exception:
return []
if callable(log) and log_responses:
try:
dumped = json.dumps(payload, ensure_ascii=False)
except Exception:
dumped = str(payload)
log(f"TMDB RESPONSE_BODY /{kind}/{{id}}/credits body={dumped[:2000]}")
cast_payload = payload.get("cast") or [] cast_payload = payload.get("cast") or []
if callable(log): if callable(log):
log(f"TMDB CREDITS /{kind}/{{id}}/credits cast={len(cast_payload)}") log(f"TMDB CREDITS /{kind}/{{id}}/credits cast={len(cast_payload)}")
with_images: List[TmdbCastMember] = [] return _parse_cast_payload(cast_payload)
without_images: List[TmdbCastMember] = []
for entry in cast_payload:
name = (entry.get("name") or "").strip()
role = (entry.get("character") or "").strip()
thumb = _image_url(entry.get("profile_path") or "", size="w185")
if not name:
continue
member = TmdbCastMember(name=name, role=role, thumb=thumb)
if thumb:
with_images.append(member)
else:
without_images.append(member)
# Viele Kodi-Skins zeigen bei fehlendem Thumbnail Platzhalter-Köpfe.
# Bevorzugt daher Cast-Einträge mit Bild; nur wenn gar keine Bilder existieren,
# geben wir Namen ohne Bild zurück.
if with_images:
return with_images[:30]
return without_images[:30]
def _parse_cast_payload(cast_payload: object) -> List[TmdbCastMember]: def _parse_cast_payload(cast_payload: object) -> List[TmdbCastMember]:
@@ -141,8 +106,8 @@ def _parse_cast_payload(cast_payload: object) -> List[TmdbCastMember]:
else: else:
without_images.append(member) without_images.append(member)
if with_images: if with_images:
return with_images[:30] return with_images[:MAX_CAST_MEMBERS]
return without_images[:30] return without_images[:MAX_CAST_MEMBERS]
def _tmdb_get_json( def _tmdb_get_json(
@@ -163,23 +128,29 @@ def _tmdb_get_json(
if callable(log): if callable(log):
log(f"TMDB GET {url}") log(f"TMDB GET {url}")
sess = session or _get_tmdb_session() or requests.Session() sess = session or _get_tmdb_session() or requests.Session()
response = None
try: try:
response = sess.get(url, timeout=timeout) response = sess.get(url, timeout=timeout)
status = getattr(response, "status_code", None)
payload: object | None = None
body_text = ""
try:
payload = response.json()
except Exception:
try:
body_text = (response.text or "").strip()
except Exception:
body_text = ""
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
if callable(log): if callable(log):
log(f"TMDB ERROR request_failed url={url} error={exc!r}") log(f"TMDB ERROR request_failed url={url} error={exc!r}")
return None, None, "" return None, None, ""
finally:
status = getattr(response, "status_code", None) if response is not None:
payload: object | None = None try:
body_text = "" response.close()
try: except Exception:
payload = response.json() pass
except Exception:
try:
body_text = (response.text or "").strip()
except Exception:
body_text = ""
if callable(log): if callable(log):
log(f"TMDB RESPONSE status={status} url={url}") log(f"TMDB RESPONSE status={status} url={url}")
@@ -214,49 +185,17 @@ def fetch_tv_episode_credits(
return [] return []
params = {"api_key": api_key, "language": (language or "de-DE").strip()} params = {"api_key": api_key, "language": (language or "de-DE").strip()}
url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}/episode/{episode_number}/credits?{urlencode(params)}" url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}/episode/{episode_number}/credits?{urlencode(params)}"
if callable(log): status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
log(f"TMDB GET {url}")
try:
response = requests.get(url, timeout=timeout)
except Exception as exc: # pragma: no cover
if callable(log):
log(f"TMDB ERROR /tv/{{id}}/season/{{n}}/episode/{{e}}/credits request_failed error={exc!r}")
return []
status = getattr(response, "status_code", None)
if callable(log): if callable(log):
log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}}/episode/{{e}}/credits status={status}") log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}}/episode/{{e}}/credits status={status}")
if status != 200: if log_responses and payload is None and body_text:
log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}}/episode/{{e}}/credits body={body_text[:2000]}")
if status != 200 or not isinstance(payload, dict):
return [] return []
try:
payload = response.json() or {}
except Exception:
return []
if callable(log) and log_responses:
try:
dumped = json.dumps(payload, ensure_ascii=False)
except Exception:
dumped = str(payload)
log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}}/episode/{{e}}/credits body={dumped[:2000]}")
cast_payload = payload.get("cast") or [] cast_payload = payload.get("cast") or []
if callable(log): if callable(log):
log(f"TMDB CREDITS /tv/{{id}}/season/{{n}}/episode/{{e}}/credits cast={len(cast_payload)}") log(f"TMDB CREDITS /tv/{{id}}/season/{{n}}/episode/{{e}}/credits cast={len(cast_payload)}")
with_images: List[TmdbCastMember] = [] return _parse_cast_payload(cast_payload)
without_images: List[TmdbCastMember] = []
for entry in cast_payload:
name = (entry.get("name") or "").strip()
role = (entry.get("character") or "").strip()
thumb = _image_url(entry.get("profile_path") or "", size="w185")
if not name:
continue
member = TmdbCastMember(name=name, role=role, thumb=thumb)
if thumb:
with_images.append(member)
else:
without_images.append(member)
if with_images:
return with_images[:30]
return without_images[:30]
def lookup_tv_show( def lookup_tv_show(
@@ -546,27 +485,13 @@ def lookup_tv_season_summary(
params = {"api_key": api_key, "language": (language or "de-DE").strip()} params = {"api_key": api_key, "language": (language or "de-DE").strip()}
url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}" url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}"
if callable(log): status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
log(f"TMDB GET {url}")
try:
response = requests.get(url, timeout=timeout)
except Exception:
return None
status = getattr(response, "status_code", None)
if callable(log): if callable(log):
log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status}") log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status}")
if status != 200: if log_responses and payload is None and body_text:
log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={body_text[:2000]}")
if status != 200 or not isinstance(payload, dict):
return None return None
try:
payload = response.json() or {}
except Exception:
return None
if callable(log) and log_responses:
try:
dumped = json.dumps(payload, ensure_ascii=False)
except Exception:
dumped = str(payload)
log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={dumped[:2000]}")
plot = (payload.get("overview") or "").strip() plot = (payload.get("overview") or "").strip()
poster_path = (payload.get("poster_path") or "").strip() poster_path = (payload.get("poster_path") or "").strip()
@@ -594,27 +519,9 @@ def lookup_tv_season(
return None return None
params = {"api_key": api_key, "language": (language or "de-DE").strip()} params = {"api_key": api_key, "language": (language or "de-DE").strip()}
url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}" url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}"
if callable(log): status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
log(f"TMDB GET {url}") episodes = (payload or {}).get("episodes") if isinstance(payload, dict) else []
try: episodes = episodes or []
response = requests.get(url, timeout=timeout)
except Exception as exc: # pragma: no cover
if callable(log):
log(f"TMDB ERROR /tv/{{id}}/season/{{n}} request_failed error={exc!r}")
return None
status = getattr(response, "status_code", None)
payload = None
body_text = ""
try:
payload = response.json() or {}
except Exception:
try:
body_text = (response.text or "").strip()
except Exception:
body_text = ""
episodes = (payload or {}).get("episodes") or []
if callable(log): if callable(log):
log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status} episodes={len(episodes)}") log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status} episodes={len(episodes)}")
if log_responses: if log_responses:

View File

@@ -1,55 +1,49 @@
# ViewIT Hauptlogik (`addon/default.py`) # ViewIT Hauptlogik (`addon/default.py`)
Dieses Dokument beschreibt den Einstiegspunkt des Addons und die zentrale Steuerlogik. Diese Datei ist der Router des Addons.
Sie verbindet Kodi UI, Plugin Calls und Playback.
## Aufgabe der Datei ## Kernaufgabe
`addon/default.py` ist der Router des Addons. Er: - Plugins laden
- lädt die PluginModule dynamisch, - Menues bauen
- stellt die KodiNavigation bereit, - Aktionen auf Plugin Methoden mappen
- übersetzt UIAktionen in PluginAufrufe, - Playback starten
- startet die Wiedergabe und verwaltet Playstate/Resume. - Playstate speichern
## Ablauf (high level) ## Ablauf
1. **PluginDiscovery**: Lädt alle `addon/plugins/*.py` (ohne `_`Prefix). Bevorzugt `Plugin = <Klasse>`, sonst werden `BasisPlugin`Subklassen deterministisch instanziiert. 1. Plugin Discovery fuer `addon/plugins/*.py` ohne `_` Prefix.
2. **Navigation**: Baut KodiListen (Serien/Staffeln/Episoden) auf Basis der PluginAntworten. 2. Navigation fuer Titel, Staffeln und Episoden.
3. **Playback**: Holt StreamLinks aus dem Plugin und startet die Wiedergabe. 3. Playback: Link holen, optional aufloesen, abspielen.
4. **Playstate**: Speichert ResumeDaten lokal (`playstate.json`) und setzt `playcount`/ResumeInfos. 4. Playstate: watched und resume in `playstate.json` schreiben.
## Routing & Aktionen ## Routing
Die Datei arbeitet mit URLParametern (KodiPluginStandard). Typische Aktionen: Der Router liest Query Parameter aus `sys.argv[2]`.
- `search` → Suche über ein Plugin Typische Aktionen:
- `seasons` → Staffeln für einen Titel - `search`
- `episodes` → Episoden für eine Staffel - `seasons`
- `play` → StreamLink auflösen und abspielen - `episodes`
- `play_episode`
- `play_movie`
- `play_episode_url`
Die genaue Aktion wird aus den QueryParametern gelesen und an das entsprechende Plugin delegiert. ## Playstate
- Speicherort: Addon Profilordner, Datei `playstate.json`
- Key: Plugin + Titel + Staffel + Episode
- Werte: watched, playcount, resume_position, resume_total
## Playstate (Resume/Watched) ## Wichtige Helper
- **Speicherort**: `playstate.json` im AddonProfilordner. - Plugin Loader und Discovery
- **Key**: Kombination aus PluginName, Titel, Staffel, Episode. - UI Builder fuer ListItems
- **Verwendung**: - Playstate Load/Save/Merge
- `playcount` wird gesetzt, wenn „gesehen“ markiert ist. - TMDB Merge mit Source Fallback
- `resume_position`/`resume_total` werden gesetzt, wenn vorhanden.
## Wichtige Hilfsfunktionen ## Fehlerverhalten
- **PluginLoader**: findet & instanziiert Plugins. - Importfehler pro Plugin werden isoliert behandelt.
- **UIHelper**: setzt ContentType, baut Verzeichnisseinträge. - Fehler in einem Plugin sollen das Addon nicht stoppen.
- **PlaystateHelper**: `_load_playstate`, `_save_playstate`, `_apply_playstate_to_info`. - User bekommt kurze Fehlermeldungen in Kodi.
- **MetadataMerge**: PluginMetadaten können TMDB übersteuern, TMDB dient als Fallback.
## Fehlerbehandlung ## Erweiterung
- PluginImportfehler werden isoliert behandelt, damit das Addon nicht komplett ausfällt. Fuer neue Aktion im Router:
- NetzwerkFehler werden in Plugins abgefangen, `default.py` sollte nur saubere Fehlermeldungen weitergeben. 1. Action im `run()` Handler registrieren.
2. ListItem mit passenden Parametern bauen.
## Debugging 3. Zielmethode im Plugin bereitstellen.
- Globale DebugSettings werden über `addon/resources/settings.xml` gesteuert.
- Plugins loggen URLs/HTML optional (siehe jeweilige PluginDoku).
## Änderungen & Erweiterungen
Für neue Aktionen:
1. Neue Aktion im Router registrieren.
2. UIEinträge passend anlegen.
3. Entsprechende PluginMethode definieren oder erweitern.
## Hinweis zur Erstellung
Teile dieser Dokumentation wurden KIgestützt erstellt und bei Bedarf manuell angepasst.

View File

@@ -1,118 +1,85 @@
# ViewIT Entwicklerdoku Plugins (`addon/plugins/*_plugin.py`) # ViewIT Plugin Entwicklung (`addon/plugins/*_plugin.py`)
Diese Doku beschreibt, wie Plugins im ViewITAddon aufgebaut sind und wie neue ProviderIntegrationen entwickelt werden. Diese Datei zeigt, wie Plugins im Projekt aufgebaut sind und wie sie mit dem Router zusammenarbeiten.
## Grundlagen ## Grundlagen
- Jedes Plugin ist eine einzelne Datei unter `addon/plugins/`. - Ein Plugin ist eine Python Datei in `addon/plugins/`.
- Dateinamen **ohne** `_`Prefix werden automatisch geladen. - Dateien mit `_` Prefix werden nicht geladen.
- Jede Datei enthält eine Klasse, die von `BasisPlugin` erbt. - Plugin Klasse erbt von `BasisPlugin`.
- Optional: `Plugin = <Klasse>` als expliziter Einstiegspunkt (bevorzugt vom Loader). - Optional: `Plugin = <Klasse>` als klarer Einstiegspunkt.
## PflichtMethoden (BasisPlugin) ## Pflichtmethoden
Jedes Plugin muss diese Methoden implementieren: Jedes Plugin implementiert:
- `async search_titles(query: str) -> list[str]` - `async search_titles(query: str) -> list[str]`
- `seasons_for(title: str) -> list[str]` - `seasons_for(title: str) -> list[str]`
- `episodes_for(title: str, season: str) -> list[str]` - `episodes_for(title: str, season: str) -> list[str]`
## Vertrag Plugin ↔ Hauptlogik (`default.py`) ## Wichtige optionale Methoden
Die Hauptlogik ruft Plugin-Methoden auf und verarbeitet ausschließlich deren Rückgaben. - `stream_link_for(...)`
- `resolve_stream_link(...)`
- `metadata_for(...)`
- `available_hosters_for(...)`
- `series_url_for_title(...)`
- `remember_series_url(...)`
- `episode_url_for(...)`
- `available_hosters_for_url(...)`
- `stream_link_for_url(...)`
Wesentliche Rückgaben an die Hauptlogik: ## Film Provider Standard
- `search_titles(...)` → Liste von Titel-Strings für die Trefferliste Wenn keine echten Staffeln existieren:
- `seasons_for(...)` → Liste von Staffel-Labels - `seasons_for(title)` gibt `['Film']`
- `episodes_for(...)` → Liste von Episoden-Labels - `episodes_for(title, 'Film')` gibt `['Stream']`
- `stream_link_for(...)` → Hoster-/Player-Link (nicht zwingend finale Media-URL)
- `resolve_stream_link(...)` → finale/spielbare URL nach Redirect/Resolver
- `metadata_for(...)` → Info-Labels/Art (Plot/Poster) aus der Quelle
- Optional `available_hosters_for(...)` → auswählbare Hoster-Namen im Dialog
- Optional `series_url_for_title(...)` → stabile Detail-URL pro Titel für Folgeaufrufe
- Optional `remember_series_url(...)` → Übernahme einer bereits bekannten Detail-URL
Standard für Film-Provider (ohne echte Staffeln): ## Capabilities
- `seasons_for(title)` gibt `["Film"]` zurück Ein Plugin kann Features melden ueber `capabilities()`.
- `episodes_for(title, "Film")` gibt `["Stream"]` zurück Bekannte Werte:
- `popular_series`
- `genres`
- `latest_episodes`
- `new_titles`
- `alpha`
- `series_catalog`
## Optionale Features (Capabilities) ## Suche
Über `capabilities()` kann das Plugin zusätzliche Funktionen anbieten: Aktuelle Regeln fuer Suchtreffer:
- `popular_series``popular_series()` - Match auf Titel
- `genres``genres()` + `titles_for_genre(genre)` - Wortbasiert
- `latest_episodes``latest_episodes(page=1)` - Keine Teilwort Treffer im selben Wort
- `new_titles``new_titles_page(page=1)` - Beschreibungen nicht fuer Match nutzen
- `alpha``alpha_index()` + `titles_for_alpha_page(letter, page)`
- `series_catalog``series_catalog_page(page=1)`
## Empfohlene Struktur ## Settings
- Konstanten für URLs/Endpoints (BASE_URL, Pfade, Templates) Pro Plugin meist `*_base_url`.
- `requests` + `bs4` optional (fehlt beides, Plugin sollte sauber deaktivieren) Beispiele:
- HelperFunktionen für Parsing und Normalisierung - `serienstream_base_url`
- Caches für Such, Staffel und EpisodenDaten - `aniworld_base_url`
- `einschalten_base_url`
- `topstream_base_url`
- `filmpalast_base_url`
- `doku_streams_base_url`
## Suche (aktuelle Policy) ## Playback Flow
- **Nur TitelMatches** 1. Episode oder Film auswaehlen.
- **Wortbasierter Match** nach Normalisierung (Lowercase + NichtAlnum → Leerzeichen) 2. Optional Hosterliste anzeigen.
- Keine Teilwort-Treffer innerhalb eines Wortes (Beispiel: `hund` matcht nicht `thunder`) 3. `stream_link_for` oder `stream_link_for_url` aufrufen.
- Keine Beschreibung/Plot/Meta für Matches 4. `resolve_stream_link` aufrufen.
5. Finale URL an Kodi geben.
## Namensgebung ## Logging
- PluginKlassenname: `XxxPlugin` Nutze Helper aus `addon/plugin_helpers.py`:
- Anzeigename (Property `name`): **mit Großbuchstaben beginnen** (z.B. `Serienstream`, `Einschalten`)
## Settings pro Plugin
Standard: `*_base_url` (Domain / BASE_URL)
- Beispiele:
- `serienstream_base_url`
- `aniworld_base_url`
- `einschalten_base_url`
- `topstream_base_url`
- `filmpalast_base_url`
- `doku_streams_base_url`
## Playback
- `stream_link_for(...)` implementieren (liefert bevorzugten Hoster-Link).
- `available_hosters_for(...)` bereitstellen, wenn die Seite mehrere Hoster anbietet.
- `resolve_stream_link(...)` nach einheitlichem Flow umsetzen:
1. Redirects auflösen (falls vorhanden)
2. ResolveURL (`resolveurl_backend.resolve`) versuchen
3. Bei Fehlschlag auf den besten verfügbaren Link zurückfallen
- Optional `set_preferred_hosters(...)` unterstützen, damit die Hoster-Auswahl aus der Hauptlogik direkt greift.
## StandardFlow (empfohlen)
1. **Suche**: nur Titel liefern und Titel→Detail-URL mappen.
2. **Navigation**: `series_url_for_title`/`remember_series_url` unterstützen, damit URLs zwischen Aufrufen stabil bleiben.
3. **Auswahl Hoster**: Hoster-Namen aus der Detailseite extrahieren und anbieten.
4. **Playback**: Hoster-Link liefern, danach konsistent über `resolve_stream_link` finalisieren.
5. **Metadaten**: `metadata_for` nutzen, Plot/Poster aus der Quelle zurückgeben.
6. **Fallbacks**: bei Layout-Unterschieden defensiv parsen und Logging aktivierbar halten.
## Debugging
Global gesteuert über Settings:
- `debug_log_urls`
- `debug_dump_html`
- `debug_show_url_info`
Plugins sollten die Helper aus `addon/plugin_helpers.py` nutzen:
- `log_url(...)` - `log_url(...)`
- `dump_response_html(...)` - `dump_response_html(...)`
- `notify_url(...)` - `notify_url(...)`
## Template ## Build und Checks
`addon/plugins/_template_plugin.py` dient als Startpunkt für neue Provider. - ZIP: `./scripts/build_kodi_zip.sh`
- Addon Ordner: `./scripts/build_install_addon.sh`
- Manifest: `python3 scripts/generate_plugin_manifest.py`
- Snapshot Checks: `python3 qa/run_plugin_snapshots.py`
## Build & Test ## Kurze Checkliste
- ZIP bauen: `./scripts/build_kodi_zip.sh` - `name` gesetzt und korrekt
- AddonOrdner: `./scripts/build_install_addon.sh` - `*_base_url` in Settings vorhanden
- PluginManifest aktualisieren: `python3 scripts/generate_plugin_manifest.py` - Suche liefert nur passende Titel
- Live-Snapshot-Checks: `python3 qa/run_plugin_snapshots.py` (aktualisieren mit `--update`) - Playback Methoden vorhanden
- Fehler und Timeouts behandelt
## BeispielCheckliste - Cache nur da, wo er Zeit spart
- [ ] `name` korrekt gesetzt
- [ ] `*_base_url` in Settings vorhanden
- [ ] Suche matcht nur Titel und wortbasiert
- [ ] `stream_link_for` + `resolve_stream_link` folgen dem Standard-Flow
- [ ] Optional: `available_hosters_for` + `set_preferred_hosters` vorhanden
- [ ] Optional: `series_url_for_title` + `remember_series_url` vorhanden
- [ ] Fehlerbehandlung und Timeouts vorhanden
- [ ] Optional: Caches für Performance
## Hinweis zur Erstellung
Teile dieser Dokumentation wurden KIgestützt erstellt und bei Bedarf manuell angepasst.

View File

@@ -1,115 +1,71 @@
## ViewIt Plugin-System # ViewIT Plugin System
Dieses Dokument beschreibt, wie das Plugin-System von **ViewIt** funktioniert und wie die Community neue Integrationen hinzufügen kann. Dieses Dokument beschreibt Laden, Vertrag und Betrieb der Plugins.
### Überblick ## Ueberblick
Der Router laedt Provider Integrationen aus `addon/plugins/*.py`.
Aktive Plugins werden instanziiert und im UI genutzt.
ViewIt lädt Provider-Integrationen dynamisch aus `addon/plugins/*.py`. Jede Datei enthält eine Klasse, die von `BasisPlugin` erbt. Beim Start werden alle Plugins instanziiert und nur aktiv genutzt, wenn sie verfügbar sind. Relevante Dateien:
- `addon/default.py`
- `addon/plugin_interface.py`
- `docs/DEFAULT_ROUTER.md`
- `docs/PLUGIN_DEVELOPMENT.md`
Weitere Details: ## Aktuelle Plugins
- `docs/DEFAULT_ROUTER.md` (Hauptlogik in `addon/default.py`) - `serienstream_plugin.py`
- `docs/PLUGIN_DEVELOPMENT.md` (Entwicklerdoku für Plugins) - `topstreamfilm_plugin.py`
- `docs/PLUGIN_MANIFEST.json` (zentraler Überblick über Plugins, Versionen, Capabilities) - `einschalten_plugin.py`
- `aniworld_plugin.py`
- `filmpalast_plugin.py`
- `dokustreams_plugin.py`
- `_template_plugin.py` (Vorlage)
### Aktuelle Plugins ## Discovery Ablauf
In `addon/default.py`:
1. Finde `*.py` in `addon/plugins/`
2. Ueberspringe Dateien mit `_` Prefix
3. Importiere Modul
4. Nutze `Plugin = <Klasse>`, falls vorhanden
5. Sonst instanziiere `BasisPlugin` Subklassen deterministisch
6. Ueberspringe Plugins mit `is_available = False`
- `serienstream_plugin.py` Serienstream (s.to) ## Basis Interface
- `topstreamfilm_plugin.py` Topstreamfilm `BasisPlugin` definiert den Kern:
- `einschalten_plugin.py` Einschalten - `search_titles`
- `aniworld_plugin.py` Aniworld - `seasons_for`
- `filmpalast_plugin.py` Filmpalast - `episodes_for`
- `dokustreams_plugin.py` Doku-Streams
- `_template_plugin.py` Vorlage für neue Plugins
### Plugin-Discovery (Ladeprozess) Weitere Methoden sind optional und werden nur genutzt, wenn vorhanden.
Der Loader in `addon/default.py`: ## Capabilities
Plugins koennen Features aktiv melden.
Typische Werte:
- `popular_series`
- `genres`
- `latest_episodes`
- `new_titles`
- `alpha`
- `series_catalog`
1. Sucht alle `*.py` in `addon/plugins/` Das UI zeigt nur Menues fuer aktiv gemeldete Features.
2. Überspringt Dateien, die mit `_` beginnen
3. Lädt Module dynamisch
4. Nutzt `Plugin = <Klasse>` als bevorzugten Einstiegspunkt (falls vorhanden)
5. Fallback: instanziert Klassen, die von `BasisPlugin` erben (deterministisch sortiert)
6. Ignoriert Plugins mit `is_available = False`
Damit bleiben fehlerhafte Plugins isoliert und blockieren nicht das gesamte Add-on. ## Metadaten Quelle
`prefer_source_metadata = True` bedeutet:
- Quelle zuerst
- TMDB nur Fallback
### Plugin-Manifest (Audit & Repro) ## Stabilitaet
`docs/PLUGIN_MANIFEST.json` listet alle Plugins mit Version, Capabilities und Basis-Settings. - Keine Netz Calls im Import Block.
Erzeugung: `python3 scripts/generate_plugin_manifest.py` - Fehler im Plugin muessen lokal behandelt werden.
- Ein defektes Plugin darf andere Plugins nicht blockieren.
### BasisPlugin verpflichtende Methoden ## Build
Kodi ZIP bauen:
Definiert in `addon/plugin_interface.py`: ```bash
- `async search_titles(query: str) -> list[str]`
- `seasons_for(title: str) -> list[str]`
- `episodes_for(title: str, season: str) -> list[str]`
- optional `metadata_for(title: str) -> (info_labels, art, cast)`
### Optionale Features (Capabilities)
Plugins können zusätzliche Features anbieten:
- `capabilities() -> set[str]`
- `popular_series`: liefert beliebte Serien
- `genres`: Genre-Liste verfügbar
- `latest_episodes`: neue Episoden verfügbar
- `new_titles`: neue Titel verfügbar
- `alpha`: A-Z Index verfügbar
- `series_catalog`: Serienkatalog verfügbar
- `popular_series() -> list[str]`
- `genres() -> list[str]`
- `titles_for_genre(genre: str) -> list[str]`
- `latest_episodes(page: int = 1) -> list[LatestEpisode]` (wenn angeboten)
- `new_titles_page(page: int = 1) -> list[str]` (wenn angeboten)
- `alpha_index() -> list[str]` (wenn angeboten)
- `series_catalog_page(page: int = 1) -> list[str]` (wenn angeboten)
Metadaten:
- `prefer_source_metadata = True` bedeutet: Plugin-Metadaten gehen vor TMDB, TMDB dient nur als Fallback.
ViewIt zeigt im UI nur die Features an, die ein Plugin tatsächlich liefert.
### Plugin-Struktur (empfohlen)
Eine Integration sollte typischerweise bieten:
- Konstante `BASE_URL`
- `search_titles()` mit Provider-Suche
- `seasons_for()` und `episodes_for()` mit HTML-Parsing
- `stream_link_for()` optional für direkte Playback-Links
- `metadata_for()` optional für Plot/Poster aus der Quelle
- Optional: `available_hosters_for()` oder Provider-spezifische Helfer
Als Startpunkt dient `addon/plugins/_template_plugin.py`.
### Community-Erweiterungen (Workflow)
1. Fork/Branch erstellen
2. Neue Datei unter `addon/plugins/` hinzufügen (z.B. `meinprovider_plugin.py`)
3. Klasse erstellen, die `BasisPlugin` implementiert
4. In Kodi testen (ZIP bauen, installieren)
5. PR öffnen
### Qualitätsrichtlinien
- Keine Netzwerkzugriffe im Import-Top-Level
- Netzwerkzugriffe nur in Methoden (z.B. `search_titles`)
- Fehler sauber abfangen und verständliche Fehlermeldungen liefern
- Kein globaler Zustand, der über Instanzen hinweg überrascht
- Provider-spezifische Parser in Helper-Funktionen kapseln
- Reproduzierbare Reihenfolge: `Plugin`-Alias nutzen oder Klassenname eindeutig halten
### Debugging & Logs
Hilfreiche Logs werden nach `userdata/addon_data/plugin.video.viewit/logs/` geschrieben.
Provider sollten URL-Logging optional halten (Settings).
### ZIP-Build
```
./scripts/build_kodi_zip.sh ./scripts/build_kodi_zip.sh
``` ```
Das ZIP liegt anschließend unter `dist/plugin.video.viewit-<version>.zip`. Ergebnis:
`dist/plugin.video.viewit-<version>.zip`

View File

@@ -21,8 +21,20 @@ fi
mkdir -p "${REPO_DIR}" mkdir -p "${REPO_DIR}"
read -r ADDON_ID ADDON_VERSION < <(python3 - "${PLUGIN_ADDON_XML}" <<'PY'
import sys
import xml.etree.ElementTree as ET
root = ET.parse(sys.argv[1]).getroot()
print(root.attrib.get("id", "plugin.video.viewit"), root.attrib.get("version", "0.0.0"))
PY
)
PLUGIN_ZIP="$("${ROOT_DIR}/scripts/build_kodi_zip.sh")" PLUGIN_ZIP="$("${ROOT_DIR}/scripts/build_kodi_zip.sh")"
cp -f "${PLUGIN_ZIP}" "${REPO_DIR}/" PLUGIN_ZIP_NAME="$(basename "${PLUGIN_ZIP}")"
PLUGIN_ADDON_DIR_IN_REPO="${REPO_DIR}/${ADDON_ID}"
mkdir -p "${PLUGIN_ADDON_DIR_IN_REPO}"
cp -f "${PLUGIN_ZIP}" "${PLUGIN_ADDON_DIR_IN_REPO}/${PLUGIN_ZIP_NAME}"
read -r REPO_ADDON_ID REPO_ADDON_VERSION < <(python3 - "${REPO_ADDON_XML}" <<'PY' read -r REPO_ADDON_ID REPO_ADDON_VERSION < <(python3 - "${REPO_ADDON_XML}" <<'PY'
import sys import sys
@@ -74,6 +86,9 @@ REPO_ZIP_NAME="${REPO_ADDON_ID}-${REPO_ADDON_VERSION}.zip"
REPO_ZIP_PATH="${REPO_DIR}/${REPO_ZIP_NAME}" REPO_ZIP_PATH="${REPO_DIR}/${REPO_ZIP_NAME}"
rm -f "${REPO_ZIP_PATH}" rm -f "${REPO_ZIP_PATH}"
python3 "${ROOT_DIR}/scripts/zip_deterministic.py" "${REPO_ZIP_PATH}" "${TMP_REPO_ADDON_DIR}" >/dev/null python3 "${ROOT_DIR}/scripts/zip_deterministic.py" "${REPO_ZIP_PATH}" "${TMP_REPO_ADDON_DIR}" >/dev/null
REPO_ADDON_DIR_IN_REPO="${REPO_DIR}/${REPO_ADDON_ID}"
mkdir -p "${REPO_ADDON_DIR_IN_REPO}"
cp -f "${REPO_ZIP_PATH}" "${REPO_ADDON_DIR_IN_REPO}/${REPO_ZIP_NAME}"
python3 - "${PLUGIN_ADDON_XML}" "${TMP_REPO_ADDON_DIR}/addon.xml" "${REPO_DIR}/addons.xml" <<'PY' python3 - "${PLUGIN_ADDON_XML}" "${TMP_REPO_ADDON_DIR}/addon.xml" "${REPO_DIR}/addons.xml" <<'PY'
import sys import sys
@@ -107,4 +122,5 @@ echo "Repo built:"
echo " ${REPO_DIR}/addons.xml" echo " ${REPO_DIR}/addons.xml"
echo " ${REPO_DIR}/addons.xml.md5" echo " ${REPO_DIR}/addons.xml.md5"
echo " ${REPO_ZIP_PATH}" echo " ${REPO_ZIP_PATH}"
echo " ${REPO_DIR}/$(basename "${PLUGIN_ZIP}")" echo " ${PLUGIN_ADDON_DIR_IN_REPO}/${PLUGIN_ZIP_NAME}"
echo " ${REPO_ADDON_DIR_IN_REPO}/${REPO_ZIP_NAME}"