diff --git a/addon/addon.xml b/addon/addon.xml index d79fb18..2ca7853 100644 --- a/addon/addon.xml +++ b/addon/addon.xml @@ -1,5 +1,5 @@ - + @@ -18,4 +18,4 @@ GPL-3.0-or-later all - \ No newline at end of file + diff --git a/addon/default.py b/addon/default.py index dc8a9ec..db9bc5c 100644 --- a/addon/default.py +++ b/addon/default.py @@ -18,6 +18,7 @@ import os import re import sys import threading +import time import xml.etree.ElementTree as ET from pathlib import Path from types import ModuleType @@ -104,6 +105,7 @@ except ImportError: # pragma: no cover - allow importing outside Kodi (e.g. lin from plugin_interface import BasisPlugin from http_session_pool import close_all_sessions +from plugin_helpers import normalize_resolved_stream_url from metadata_utils import ( collect_plugin_metadata as _collect_plugin_metadata, merge_metadata as _merge_metadata, @@ -127,6 +129,8 @@ _PLAYSTATE_CACHE: dict[str, dict[str, object]] | None = None _PLAYSTATE_LOCK = threading.RLock() _TMDB_LOCK = threading.RLock() WATCHED_THRESHOLD = 0.9 +POPULAR_MENU_LABEL = "Haeufig gesehen" +LATEST_MENU_LABEL = "Neuste Titel" atexit.register(close_all_sessions) @@ -306,117 +310,26 @@ def _playstate_path() -> str: def _load_playstate() -> dict[str, dict[str, object]]: - global _PLAYSTATE_CACHE - with _PLAYSTATE_LOCK: - if _PLAYSTATE_CACHE is not None: - return _PLAYSTATE_CACHE - path = _playstate_path() - try: - if xbmcvfs and xbmcvfs.exists(path): - handle = xbmcvfs.File(path) - raw = handle.read() - handle.close() - else: - with open(path, "r", encoding="utf-8") as handle: - raw = handle.read() - data = json.loads(raw or "{}") - if isinstance(data, dict): - normalized: dict[str, dict[str, object]] = {} - for key, value in data.items(): - if isinstance(key, str) and isinstance(value, dict): - normalized[key] = dict(value) - _PLAYSTATE_CACHE = normalized - return normalized - except Exception: - pass - _PLAYSTATE_CACHE = {} - return {} + return {} def _save_playstate(state: dict[str, dict[str, object]]) -> None: - global _PLAYSTATE_CACHE - with _PLAYSTATE_LOCK: - _PLAYSTATE_CACHE = state - path = _playstate_path() - try: - payload = json.dumps(state, ensure_ascii=False, sort_keys=True) - except Exception: - return - try: - if xbmcvfs: - directory = os.path.dirname(path) - if directory and not xbmcvfs.exists(directory): - xbmcvfs.mkdirs(directory) - handle = xbmcvfs.File(path, "w") - handle.write(payload) - handle.close() - else: - with open(path, "w", encoding="utf-8") as handle: - handle.write(payload) - except Exception: - return + return def _get_playstate(key: str) -> dict[str, object]: - return dict(_load_playstate().get(key, {}) or {}) + return {} def _set_playstate(key: str, value: dict[str, object]) -> None: - state = _load_playstate() - if value: - state[key] = dict(value) - else: - state.pop(key, None) - _save_playstate(state) + return def _apply_playstate_to_info(info_labels: dict[str, object], playstate: dict[str, object]) -> dict[str, object]: - info_labels = dict(info_labels or {}) - watched = bool(playstate.get("watched") or False) - resume_position = playstate.get("resume_position") - resume_total = playstate.get("resume_total") - if watched: - info_labels["playcount"] = 1 - info_labels.pop("resume_position", None) - info_labels.pop("resume_total", None) - else: - try: - pos = int(resume_position) if resume_position is not None else 0 - tot = int(resume_total) if resume_total is not None else 0 - except Exception: - pos, tot = 0, 0 - if pos > 0 and tot > 0: - info_labels["resume_position"] = pos - info_labels["resume_total"] = tot - return info_labels - - -def _time_label(seconds: int) -> str: - try: - seconds = int(seconds or 0) - except Exception: - seconds = 0 - if seconds <= 0: - return "" - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - secs = seconds % 60 - if hours > 0: - return f"{hours:02d}:{minutes:02d}:{secs:02d}" - return f"{minutes:02d}:{secs:02d}" + return dict(info_labels or {}) def _label_with_playstate(label: str, playstate: dict[str, object]) -> str: - watched = bool(playstate.get("watched") or False) - if watched: - return f"[gesehen] {label}" - resume_pos = playstate.get("resume_position") - try: - pos = int(resume_pos) if resume_pos is not None else 0 - except Exception: - pos = 0 - if pos > 0: - return f"[fortsetzen {_time_label(pos)}] {label}" return label @@ -1059,6 +972,7 @@ def _normalize_update_info_url(raw: str) -> str: UPDATE_CHANNEL_MAIN = 0 UPDATE_CHANNEL_NIGHTLY = 1 UPDATE_CHANNEL_CUSTOM = 2 +AUTO_UPDATE_INTERVAL_SEC = 6 * 60 * 60 def _selected_update_channel() -> int: @@ -1176,11 +1090,8 @@ def _show_plugin_menu(plugin_name: str) -> None: _add_directory_item(handle, "Suche", "plugin_search", {"plugin": plugin_name}, is_folder=True) - if _plugin_has_capability(plugin, "new_titles"): - _add_directory_item(handle, "Neue Titel", "new_titles", {"plugin": plugin_name, "page": "1"}, is_folder=True) - - if _plugin_has_capability(plugin, "latest_episodes"): - _add_directory_item(handle, "Neueste Folgen", "latest_episodes", {"plugin": plugin_name, "page": "1"}, is_folder=True) + if _plugin_has_capability(plugin, "new_titles") or _plugin_has_capability(plugin, "latest_episodes"): + _add_directory_item(handle, LATEST_MENU_LABEL, "latest_titles", {"plugin": plugin_name, "page": "1"}, is_folder=True) if _plugin_has_capability(plugin, "genres"): _add_directory_item(handle, "Genres", "genres", {"plugin": plugin_name}, is_folder=True) @@ -1192,7 +1103,7 @@ def _show_plugin_menu(plugin_name: str) -> None: _add_directory_item(handle, "Serien", "series_catalog", {"plugin": plugin_name, "page": "1"}, is_folder=True) if _plugin_has_capability(plugin, "popular_series"): - _add_directory_item(handle, "Beliebte Serien", "popular", {"plugin": plugin_name, "page": "1"}, is_folder=True) + _add_directory_item(handle, POPULAR_MENU_LABEL, "popular", {"plugin": plugin_name, "page": "1"}, is_folder=True) xbmcplugin.endOfDirectory(handle) @@ -2139,7 +2050,7 @@ def _show_category_titles_page(plugin_name: str, category: str, page: int = 1) - if show_next: _add_directory_item( handle, - "Nächste Seite", + "Naechste Seite", "category_titles_page", {"plugin": plugin_name, "category": category, "page": str(page + 1)}, is_folder=True, @@ -2259,7 +2170,7 @@ def _show_genre_titles_page(plugin_name: str, genre: str, page: int = 1) -> None if show_next: _add_directory_item( handle, - "Nächste Seite", + "Naechste Seite", "genre_titles_page", {"plugin": plugin_name, "genre": genre, "page": str(page + 1)}, is_folder=True, @@ -2412,7 +2323,7 @@ def _show_alpha_titles_page(plugin_name: str, letter: str, page: int = 1) -> Non if show_next: _add_directory_item( handle, - "Nächste Seite", + "Naechste Seite", "alpha_titles_page", {"plugin": plugin_name, "letter": letter, "page": str(page + 1)}, is_folder=True, @@ -2531,7 +2442,7 @@ def _show_series_catalog(plugin_name: str, page: int = 1) -> None: if show_next: _add_directory_item( handle, - "Nächste Seite", + "Naechste Seite", "series_catalog", {"plugin": plugin_name, "page": str(page + 1)}, is_folder=True, @@ -2669,14 +2580,14 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: if plugin_name: plugin = _discover_plugins().get(plugin_name) if plugin is None or not _plugin_has_capability(plugin, "popular_series"): - xbmcgui.Dialog().notification("Beliebte Serien", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcgui.Dialog().notification(POPULAR_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return try: popular_getter = getattr(plugin, "popular_series", None) if callable(popular_getter): titles = _run_with_progress( - "Beliebte Serien", + POPULAR_MENU_LABEL, f"{plugin_name}: Liste wird geladen...", lambda: list(popular_getter() or []), ) @@ -2686,13 +2597,13 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: titles = [] else: titles = _run_with_progress( - "Beliebte Serien", + POPULAR_MENU_LABEL, f"{plugin_name}: Liste wird geladen...", lambda: list(plugin.titles_for_genre(label) or []), ) except Exception as exc: - _log(f"Beliebte Serien konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification("Beliebte Serien", "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) + _log(f"{POPULAR_MENU_LABEL} konnte nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification(POPULAR_MENU_LABEL, "Serien konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return @@ -2701,7 +2612,7 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: total = len(titles) total_pages = max(1, (total + page_size - 1) // page_size) page = min(page, total_pages) - xbmcplugin.setPluginCategory(handle, f"Beliebte Serien [{plugin_name}] ({page}/{total_pages})") + xbmcplugin.setPluginCategory(handle, f"{POPULAR_MENU_LABEL} [{plugin_name}] ({page}/{total_pages})") _set_content(handle, "tvshows") if total_pages > 1 and page > 1: @@ -2735,7 +2646,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): tmdb_titles.append(title) if show_tmdb and tmdb_titles: - with _busy_dialog("Beliebte Titel werden geladen..."): + with _busy_dialog(f"{POPULAR_MENU_LABEL} wird geladen..."): tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) for title in page_items: tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) @@ -2762,7 +2673,7 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: if total_pages > 1 and page < total_pages: _add_directory_item( handle, - "Nächste Seite", + "Naechste Seite", "popular", {"plugin": plugin_name, "page": str(page + 1)}, is_folder=True, @@ -2772,15 +2683,15 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: sources = _plugins_with_popular() if not sources: - xbmcgui.Dialog().notification("Beliebte Serien", "Keine Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcgui.Dialog().notification(POPULAR_MENU_LABEL, "Keine Quellen gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return - xbmcplugin.setPluginCategory(handle, "Beliebte Serien") + xbmcplugin.setPluginCategory(handle, POPULAR_MENU_LABEL) for name, plugin, _label in sources: _add_directory_item( handle, - f"Beliebte Serien [{plugin.name}]", + f"{POPULAR_MENU_LABEL} [{plugin.name}]", "popular", {"plugin": name, "page": "1"}, is_folder=True, @@ -2788,7 +2699,7 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: xbmcplugin.endOfDirectory(handle) -def _show_new_titles(plugin_name: str, page: int = 1) -> None: +def _show_new_titles(plugin_name: str, page: int = 1, *, action_name: str = "new_titles") -> None: handle = _get_handle() page_size = 10 page = max(1, int(page or 1)) @@ -2796,13 +2707,13 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None: plugin_name = (plugin_name or "").strip() plugin = _discover_plugins().get(plugin_name) if plugin is None or not _plugin_has_capability(plugin, "new_titles"): - xbmcgui.Dialog().notification("Neue Titel", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return getter = getattr(plugin, "new_titles", None) if not callable(getter): - xbmcgui.Dialog().notification("Neue Titel", "Diese Liste ist nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Diese Liste ist nicht verfuegbar.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return @@ -2810,25 +2721,25 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None: has_more_getter = getattr(plugin, "new_titles_has_more", None) if callable(paging_getter): - xbmcplugin.setPluginCategory(handle, f"Neue Titel [{plugin_name}] ({page})") + xbmcplugin.setPluginCategory(handle, f"{LATEST_MENU_LABEL} [{plugin_name}] ({page})") _set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows") if page > 1: _add_directory_item( handle, "Vorherige Seite", - "new_titles", + action_name, {"plugin": plugin_name, "page": str(page - 1)}, is_folder=True, ) try: page_items = _run_with_progress( - "Neue Titel", + LATEST_MENU_LABEL, f"{plugin_name}: Seite {page} wird geladen...", lambda: list(paging_getter(page) or []), ) except Exception as exc: - _log(f"Neue Titel konnten nicht geladen werden ({plugin_name} p{page}): {exc}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification("Neue Titel", "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) + _log(f"{LATEST_MENU_LABEL} konnten nicht geladen werden ({plugin_name} p{page}): {exc}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return page_items = [str(t).strip() for t in page_items if t and str(t).strip()] @@ -2836,13 +2747,13 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None: else: try: titles = _run_with_progress( - "Neue Titel", + LATEST_MENU_LABEL, f"{plugin_name}: Liste wird geladen...", lambda: list(getter() or []), ) except Exception as exc: - _log(f"Neue Titel konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification("Neue Titel", "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) + _log(f"{LATEST_MENU_LABEL} konnten nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Titel konnten nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return @@ -2851,21 +2762,21 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None: total = len(titles) if total == 0: xbmcgui.Dialog().notification( - "Neue Titel", + LATEST_MENU_LABEL, "Keine Titel gefunden. Bitte Basis-URL oder Index pruefen.", xbmcgui.NOTIFICATION_INFO, 4000, ) total_pages = max(1, (total + page_size - 1) // page_size) page = min(page, total_pages) - xbmcplugin.setPluginCategory(handle, f"Neue Titel [{plugin_name}] ({page}/{total_pages})") + xbmcplugin.setPluginCategory(handle, f"{LATEST_MENU_LABEL} [{plugin_name}] ({page}/{total_pages})") _set_content(handle, "movies" if plugin_name.casefold() == "einschalten" else "tvshows") if total_pages > 1 and page > 1: _add_directory_item( handle, "Vorherige Seite", - "new_titles", + action_name, {"plugin": plugin_name, "page": str(page - 1)}, is_folder=True, ) @@ -2891,7 +2802,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): tmdb_titles.append(title) if show_tmdb and tmdb_titles: - with _busy_dialog("Neue Titel werden geladen..."): + with _busy_dialog(f"{LATEST_MENU_LABEL} wird geladen..."): tmdb_prefetched = _tmdb_labels_and_art_bulk(tmdb_titles) for title in page_items: tmdb_info, tmdb_art, tmdb_cast = tmdb_prefetched.get(title, ({}, {}, [])) if show_tmdb else ({}, {}, []) @@ -2930,8 +2841,8 @@ def _show_new_titles(plugin_name: str, page: int = 1) -> None: if show_next: _add_directory_item( handle, - "Nächste Seite", - "new_titles", + "Naechste Seite", + action_name, {"plugin": plugin_name, "page": str(page + 1)}, is_folder=True, ) @@ -2943,28 +2854,28 @@ def _show_latest_episodes(plugin_name: str, page: int = 1) -> None: plugin_name = (plugin_name or "").strip() plugin = _discover_plugins().get(plugin_name) if not plugin: - xbmcgui.Dialog().notification("Neueste Folgen", "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return getter = getattr(plugin, "latest_episodes", None) if not callable(getter): - xbmcgui.Dialog().notification("Neueste Folgen", "Diese Quelle bietet das nicht an.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Diese Quelle bietet das nicht an.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return - xbmcplugin.setPluginCategory(handle, f"{plugin_name}: Neueste Folgen") + xbmcplugin.setPluginCategory(handle, f"{plugin_name}: {LATEST_MENU_LABEL}") _set_content(handle, "episodes") try: entries = _run_with_progress( - "Neueste Folgen", + LATEST_MENU_LABEL, f"{plugin_name}: Seite {page} wird geladen...", lambda: list(getter(page) or []), ) except Exception as exc: - _log(f"Neueste Folgen fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) - xbmcgui.Dialog().notification("Neueste Folgen", "Abruf fehlgeschlagen.", xbmcgui.NOTIFICATION_INFO, 3000) + _log(f"{LATEST_MENU_LABEL} fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Abruf fehlgeschlagen.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcplugin.endOfDirectory(handle) return @@ -3017,6 +2928,25 @@ def _show_latest_episodes(plugin_name: str, page: int = 1) -> None: xbmcplugin.endOfDirectory(handle) +def _show_latest_titles(plugin_name: str, page: int = 1) -> None: + plugin_name = (plugin_name or "").strip() + plugin = _discover_plugins().get(plugin_name) + if plugin is None: + handle = _get_handle() + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Quelle nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + return + if _plugin_has_capability(plugin, "latest_episodes"): + _show_latest_episodes(plugin_name, page) + return + if _plugin_has_capability(plugin, "new_titles"): + _show_new_titles(plugin_name, page, action_name="latest_titles") + return + handle = _get_handle() + xbmcgui.Dialog().notification(LATEST_MENU_LABEL, "Diese Quelle bietet das nicht an.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + + def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page: int = 1) -> None: handle = _get_handle() page_size = 10 @@ -3105,7 +3035,7 @@ def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page if show_next: _add_directory_item( handle, - "Nächste Seite", + "Naechste Seite", "genre_series_group", {"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page + 1)}, is_folder=True, @@ -3186,7 +3116,7 @@ def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page if total_pages > 1 and page < total_pages: _add_directory_item( handle, - "Nächste Seite", + "Naechste Seite", "genre_series_group", {"plugin": plugin_name, "genre": genre, "group": group_code, "page": str(page + 1)}, is_folder=True, @@ -3202,8 +3132,8 @@ def _open_settings() -> None: addon.openSettings() -def _run_update_check() -> None: - """Stoesst Kodi-Repo- und Addon-Updates an und informiert den Benutzer.""" +def _run_update_check(*, silent: bool = False) -> None: + """Stoesst Kodi-Repo- und Addon-Updates an.""" if xbmc is None: # pragma: no cover - outside Kodi return try: @@ -3214,14 +3144,32 @@ def _run_update_check() -> None: if callable(builtin): builtin("UpdateAddonRepos") builtin("UpdateLocalAddons") - builtin("ActivateWindow(addonbrowser,addons://updates/)") - xbmcgui.Dialog().notification("Updates", "Update-Check gestartet.", xbmcgui.NOTIFICATION_INFO, 4000) + if not silent: + builtin("ActivateWindow(addonbrowser,addons://updates/)") + if not silent: + xbmcgui.Dialog().notification("Updates", "Update-Check gestartet.", xbmcgui.NOTIFICATION_INFO, 4000) except Exception as exc: _log(f"Update-Pruefung fehlgeschlagen: {exc}", xbmc.LOGWARNING) - try: - xbmcgui.Dialog().notification("Updates", "Update-Check fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4000) - except Exception: - pass + if not silent: + try: + xbmcgui.Dialog().notification("Updates", "Update-Check fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4000) + except Exception: + pass + + +def _maybe_run_auto_update_check(action: str | None) -> None: + action = (action or "").strip() + # Auto-Check nur beim Root-Menue, nicht in jedem Untermenue. + if action: + return + if not _get_setting_bool("auto_update_enabled", default=False): + return + now = int(time.time()) + last = _get_setting_int("auto_update_last_ts", default=0) + if last > 0 and (now - last) < AUTO_UPDATE_INTERVAL_SEC: + return + _set_setting_string("auto_update_last_ts", str(now)) + _run_update_check(silent=True) def _extract_first_int(value: str) -> int | None: @@ -3295,81 +3243,12 @@ def _play_final_link( def _track_playback_and_update_state(key: str) -> None: - if not key: - return - monitor = xbmc.Monitor() if xbmc is not None and hasattr(xbmc, "Monitor") else None - player = xbmc.Player() - - # Wait for playback start. - started = False - for _ in range(30): - try: - if player.isPlayingVideo(): - started = True - break - except Exception: - pass - if monitor and monitor.waitForAbort(0.5): - return - if not started: - return - - last_pos = 0.0 - total = 0.0 - while True: - try: - if not player.isPlayingVideo(): - break - last_pos = float(player.getTime() or 0.0) - total = float(player.getTotalTime() or 0.0) - except Exception: - pass - if monitor and monitor.waitForAbort(1.0): - return - - if total <= 0.0: - return - percent = max(0.0, min(1.0, last_pos / total)) - state: dict[str, object] = {"last_position": int(last_pos), "resume_total": int(total), "percent": percent} - if percent >= WATCHED_THRESHOLD: - state["watched"] = True - state["resume_position"] = 0 - elif last_pos > 0: - state["watched"] = False - state["resume_position"] = int(last_pos) - _set_playstate(key, state) - - # Zusätzlich aggregiert speichern, damit Titel-/Staffel-Listen "gesehen/fortsetzen" - # anzeigen können (für Filme/Serien gleichermaßen). - try: - parts = str(key).split("\t") - if len(parts) == 4: - plugin_name, title, season, _episode = parts - plugin_name = (plugin_name or "").strip() - title = (title or "").strip() - season = (season or "").strip() - if plugin_name and title: - _set_playstate(_playstate_key(plugin_name=plugin_name, title=title, season="", episode=""), state) - if season: - _set_playstate(_playstate_key(plugin_name=plugin_name, title=title, season=season, episode=""), state) - except Exception: - pass + return def _track_playback_and_update_state_async(key: str) -> None: - """Startet Playstate-Tracking im Hintergrund, damit die UI nicht blockiert.""" - key = (key or "").strip() - if not key: - return - - def _worker() -> None: - try: - _track_playback_and_update_state(key) - except Exception: - pass - - worker = threading.Thread(target=_worker, name="viewit-playstate-tracker", daemon=True) - worker.start() + # Eigenes Resume/Watched ist deaktiviert; Kodi verwaltet das selbst. + return def _play_episode( @@ -3456,6 +3335,7 @@ def _play_episode( return _log(f"Stream-Link: {link}", xbmc.LOGDEBUG) final_link = plugin.resolve_stream_link(link) or link + final_link = normalize_resolved_stream_url(final_link, source_url=link) finally: if restore_hosters is not None and callable(preferred_setter): preferred_setter(restore_hosters) @@ -3543,6 +3423,7 @@ def _play_episode_url( return _log(f"Stream-Link: {link}", xbmc.LOGDEBUG) final_link = plugin.resolve_stream_link(link) or link + final_link = normalize_resolved_stream_url(final_link, source_url=link) finally: if restore_hosters is not None and callable(preferred_setter): preferred_setter(restore_hosters) @@ -3582,6 +3463,7 @@ def run() -> None: params = _parse_params() action = params.get("action") _log(f"Action: {action}", xbmc.LOGDEBUG) + _maybe_run_auto_update_check(action) if action == "search": _show_search() elif action == "plugin_menu": @@ -3594,6 +3476,11 @@ def run() -> None: _show_genres(params.get("plugin", "")) elif action == "categories": _show_categories(params.get("plugin", "")) + elif action == "latest_titles": + _show_latest_titles( + params.get("plugin", ""), + _parse_positive_int(params.get("page", "1"), default=1), + ) elif action == "new_titles": _show_new_titles( params.get("plugin", ""), diff --git a/addon/plugin_helpers.py b/addon/plugin_helpers.py index a21c038..31c4d42 100644 --- a/addon/plugin_helpers.py +++ b/addon/plugin_helpers.py @@ -15,7 +15,9 @@ from __future__ import annotations from datetime import datetime import hashlib import os +import re from typing import Optional +from urllib.parse import parse_qsl, urlencode try: # pragma: no cover - Kodi runtime import xbmcaddon # type: ignore[import-not-found] @@ -237,3 +239,40 @@ def dump_response_html( max_files = get_setting_int(addon_id, max_files_setting_id, default=200) _prune_dump_files(log_dir, prefix=filename_prefix, max_files=max_files) _append_text_file(path, content) + + +def normalize_resolved_stream_url(final_url: str, *, source_url: str = "") -> str: + """Normalisiert hoster-spezifische Header im finalen Stream-Link. + + `final_url` kann ein Kodi-Header-Suffix enthalten: `url|Key=Value&...`. + Die Funktion passt nur bekannte Problemfaelle an und laesst sonst alles unveraendert. + """ + + url = (final_url or "").strip() + if not url: + return "" + normalized = _normalize_supervideo_serversicuro(url, source_url=source_url) + return normalized + + +def _normalize_supervideo_serversicuro(final_url: str, *, source_url: str = "") -> str: + if "serversicuro.cc/hls/" not in final_url.casefold() or "|" not in final_url: + return final_url + + source = (source_url or "").strip() + code_match = re.search( + r"supervideo\.(?:tv|cc)/(?:e/)?([a-z0-9]+)(?:\\.html)?", + source, + flags=re.IGNORECASE, + ) + if not code_match: + return final_url + + code = (code_match.group(1) or "").strip() + if not code: + return final_url + + media_url, header_suffix = final_url.split("|", 1) + headers = dict(parse_qsl(header_suffix, keep_blank_values=True)) + headers["Referer"] = f"https://supervideo.cc/e/{code}" + return f"{media_url}|{urlencode(headers)}" diff --git a/addon/plugins/aniworld_plugin.py b/addon/plugins/aniworld_plugin.py index a73f79c..943e865 100644 --- a/addon/plugins/aniworld_plugin.py +++ b/addon/plugins/aniworld_plugin.py @@ -833,20 +833,55 @@ class AniworldPlugin(BasisPlugin): merged_poster = (poster or old_poster or "").strip() self._title_meta[title] = (merged_plot, merged_poster) - def _extract_series_metadata(self, soup: BeautifulSoupT) -> tuple[str, str]: + @staticmethod + def _is_series_image_url(url: str) -> bool: + value = (url or "").strip().casefold() + if not value: + return False + blocked = ( + "/public/img/facebook", + "/public/img/logo", + "aniworld-logo", + "favicon", + "/public/img/german.svg", + "/public/img/japanese-", + ) + return not any(marker in value for marker in blocked) + + @staticmethod + def _extract_style_url(style_value: str) -> str: + style_value = (style_value or "").strip() + if not style_value: + return "" + match = re.search(r"url\((['\"]?)(.*?)\1\)", style_value, flags=re.IGNORECASE) + if not match: + return "" + return (match.group(2) or "").strip() + + def _extract_series_metadata(self, soup: BeautifulSoupT) -> tuple[str, str, str]: if not soup: - return "", "" + return "", "", "" plot = "" poster = "" + fanart = "" - for selector in ("meta[property='og:description']", "meta[name='description']"): - node = soup.select_one(selector) - if node is None: - continue - content = (node.get("content") or "").strip() - if content: - plot = content - break + root = soup.select_one("#series") or soup + + description_node = root.select_one("p.seri_des") + if description_node is not None: + full_text = (description_node.get("data-full-description") or "").strip() + short_text = (description_node.get_text(" ", strip=True) or "").strip() + plot = full_text or short_text + + if not plot: + for selector in ("meta[property='og:description']", "meta[name='description']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + plot = content + break if not plot: for selector in (".series-description", ".seri_des", ".description", "article p"): node = soup.select_one(selector) @@ -857,25 +892,61 @@ class AniworldPlugin(BasisPlugin): plot = text break - for selector in ("meta[property='og:image']", "meta[name='twitter:image']"): - node = soup.select_one(selector) - if node is None: - continue - content = (node.get("content") or "").strip() - if content: - poster = _absolute_url(content) - break + cover = root.select_one("div.seriesCoverBox img[itemprop='image'], div.seriesCoverBox img") + if cover is not None: + for attr in ("data-src", "src"): + value = (cover.get(attr) or "").strip() + if value: + candidate = _absolute_url(value) + if self._is_series_image_url(candidate): + poster = candidate + break + if not poster: - for selector in ("img.seriesCoverBox", ".seriesCoverBox img", "img[alt][src]"): + for selector in ("meta[property='og:image']", "meta[name='twitter:image']"): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + candidate = _absolute_url(content) + if self._is_series_image_url(candidate): + poster = candidate + break + if not poster: + for selector in ("img.seriesCoverBox", ".seriesCoverBox img"): image = soup.select_one(selector) if image is None: continue value = (image.get("data-src") or image.get("src") or "").strip() if value: - poster = _absolute_url(value) - break + candidate = _absolute_url(value) + if self._is_series_image_url(candidate): + poster = candidate + break - return plot, poster + backdrop_node = root.select_one("section.title .backdrop, .SeriesSection .backdrop, .backdrop") + if backdrop_node is not None: + raw_style = (backdrop_node.get("style") or "").strip() + style_url = self._extract_style_url(raw_style) + if style_url: + candidate = _absolute_url(style_url) + if self._is_series_image_url(candidate): + fanart = candidate + + if not fanart: + for selector in ("meta[property='og:image']",): + node = soup.select_one(selector) + if node is None: + continue + content = (node.get("content") or "").strip() + if content: + candidate = _absolute_url(content) + if self._is_series_image_url(candidate): + fanart = candidate + break + + return plot, poster, fanart @staticmethod def _season_links_cache_name(series_url: str) -> str: @@ -1031,14 +1102,17 @@ class AniworldPlugin(BasisPlugin): try: soup = _get_soup(series.url, session=get_requests_session("aniworld", headers=HEADERS)) - plot, poster = self._extract_series_metadata(soup) + plot, poster, fanart = self._extract_series_metadata(soup) except Exception: - plot, poster = "", "" + plot, poster, fanart = "", "", "" if plot: info["plot"] = plot if poster: art = {"thumb": poster, "poster": poster} + if fanart: + art["fanart"] = fanart + art["landscape"] = fanart self._store_title_meta(title, plot=info.get("plot", ""), poster=poster) return info, art, None diff --git a/addon/plugins/filmpalast_plugin.py b/addon/plugins/filmpalast_plugin.py index 721421f..cbabffd 100644 --- a/addon/plugins/filmpalast_plugin.py +++ b/addon/plugins/filmpalast_plugin.py @@ -735,44 +735,49 @@ class FilmpalastPlugin(BasisPlugin): def _extract_detail_metadata(self, soup: BeautifulSoupT) -> tuple[str, str]: if not soup: return "", "" + root = soup.select_one("div#content[role='main']") or soup + detail = root.select_one("article.detail") or root plot = "" poster = "" - for selector in ("meta[property='og:description']", "meta[name='description']"): - node = soup.select_one(selector) - if node is None: - continue - content = (node.get("content") or "").strip() - if content: - plot = content - break + # Filmpalast Detailseite: bevorzugt den dedizierten Filmhandlung-Block. + plot_node = detail.select_one( + "li[itemtype='http://schema.org/Movie'] span[itemprop='description']" + ) + if plot_node is not None: + plot = (plot_node.get_text(" ", strip=True) or "").strip() if not plot: - for selector in (".toggle-content .coverDetails", ".entry-content p", "article p"): - node = soup.select_one(selector) + hidden_plot = detail.select_one("cite span.hidden") + if hidden_plot is not None: + plot = (hidden_plot.get_text(" ", strip=True) or "").strip() + if not plot: + for selector in ("meta[property='og:description']", "meta[name='description']"): + node = root.select_one(selector) if node is None: continue - text = (node.get_text(" ", strip=True) or "").strip() - if text and len(text) > 40: - plot = text + content = (node.get("content") or "").strip() + if content: + plot = content break - for selector in ("meta[property='og:image']", "meta[name='twitter:image']"): - node = soup.select_one(selector) - if node is None: - continue - content = (node.get("content") or "").strip() - if content: - poster = _absolute_url(content) - break + # Filmpalast Detailseite: Cover liegt stabil in `img.cover2`. + cover = detail.select_one("img.cover2") + if cover is not None: + value = (cover.get("data-src") or cover.get("src") or "").strip() + if value: + candidate = _absolute_url(value) + lower = candidate.casefold() + if "/themes/" not in lower and "spacer.gif" not in lower and "/files/movies/" in lower: + poster = candidate if not poster: - for selector in ("img.cover", "article img", ".entry-content img"): - image = soup.select_one(selector) - if image is None: - continue - value = (image.get("data-src") or image.get("src") or "").strip() + thumb_node = detail.select_one("li[itemtype='http://schema.org/Movie'] img[itemprop='image']") + if thumb_node is not None: + value = (thumb_node.get("data-src") or thumb_node.get("src") or "").strip() if value: - poster = _absolute_url(value) - break + candidate = _absolute_url(value) + lower = candidate.casefold() + if "/themes/" not in lower and "spacer.gif" not in lower and "/files/movies/" in lower: + poster = candidate return plot, poster diff --git a/addon/plugins/serienstream_plugin.py b/addon/plugins/serienstream_plugin.py index 9b82cfd..cfdb1f0 100644 --- a/addon/plugins/serienstream_plugin.py +++ b/addon/plugins/serienstream_plugin.py @@ -1096,7 +1096,7 @@ class SerienstreamPlugin(BasisPlugin): name = "Serienstream" version = "1.0.0" - POPULAR_GENRE_LABEL = "⭐ Beliebte Serien" + POPULAR_GENRE_LABEL = "Haeufig gesehen" def __init__(self) -> None: self._series_results: Dict[str, SeriesResult] = {} diff --git a/addon/plugins/topstreamfilm_plugin.py b/addon/plugins/topstreamfilm_plugin.py index 7cd8680..ab71fd6 100644 --- a/addon/plugins/topstreamfilm_plugin.py +++ b/addon/plugins/topstreamfilm_plugin.py @@ -20,7 +20,7 @@ import os import re import json from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional -from urllib.parse import urlencode, urljoin +from urllib.parse import urljoin try: # pragma: no cover - optional dependency import requests diff --git a/addon/resources/settings.xml b/addon/resources/settings.xml index bd96cdb..1092fac 100644 --- a/addon/resources/settings.xml +++ b/addon/resources/settings.xml @@ -72,9 +72,11 @@ + - + +