From 0580aa2efe3137a9c3f09e19d905a8c6419389de Mon Sep 17 00:00:00 2001 From: Therand90 <135005486+Therand90@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:02:37 +0200 Subject: [PATCH 1/2] fix: reuse Elementum packs for PlayNext --- lib/gui/base_window.py | 25 +- lib/gui/custom_dialogs.py | 23 +- lib/player.py | 203 ++++++--- lib/utils/parsers/pack_parser.py | 186 +++++++++ lib/utils/player/utils.py | 137 ++++++- tests/unit/test_autoscrape.py | 29 +- tests/unit/test_elementum_pack_playnext.py | 409 +++++++++++++++++++ tests/unit/test_pack_parser.py | 82 ++++ tests/unit/test_playnext_behavior.py | 22 +- tests/unit/test_playnext_internal_handoff.py | 116 ++++++ tests/unit/test_playnext_session_guard.py | 137 +++++++ tests/unit/test_stremio_playback_fidelity.py | 2 +- 12 files changed, 1293 insertions(+), 78 deletions(-) create mode 100644 lib/utils/parsers/pack_parser.py create mode 100644 tests/unit/test_elementum_pack_playnext.py create mode 100644 tests/unit/test_pack_parser.py create mode 100644 tests/unit/test_playnext_internal_handoff.py create mode 100644 tests/unit/test_playnext_session_guard.py diff --git a/lib/gui/base_window.py b/lib/gui/base_window.py index e8a638c5..cde618d3 100644 --- a/lib/gui/base_window.py +++ b/lib/gui/base_window.py @@ -13,6 +13,7 @@ ) from lib.utils.general.utils import Indexer, IndexerType, truncate_text from lib.utils.kodi.logging import summarize_locator_for_log +from lib.utils.parsers.pack_parser import detect_pack_scope from lib.utils.kodi.utils import ADDON, kodilog, translation from lib.clients.stremio.playback import resolve_stremio_playback_url @@ -237,6 +238,22 @@ def prepare_source_data( pack_select: bool = False, ) -> Dict[str, Any]: """Prepare the source data dictionary for resolving playback.""" + tv_data = self.item_information.get("tv_data") + current_season = tv_data.get("season") if isinstance(tv_data, dict) else None + pack_scope = detect_pack_scope( + source.title, + current_season=current_season, + source_is_pack=bool(source.isPack or pack_select), + ) + kodilog( + "Playback pack scope: type={!r}, seasons={!r}, reason={!r}, source={!r}".format( + pack_scope["pack_type"], + pack_scope["pack_seasons"], + pack_scope["pack_reason"], + source.title, + ) + ) + playback_data = { "type": source.type, "indexer": source.indexer, @@ -246,11 +263,15 @@ def prepare_source_data( "title": self.item_information.get("title") or self.item_information.get("query") or source.title, + "source_title": source.title, "is_torrent": is_torrent, - "is_pack": pack_select, + "is_pack": pack_scope["is_pack"], + "pack_type": pack_scope["pack_type"], + "pack_seasons": pack_scope["pack_seasons"], + "pack_reason": pack_scope["pack_reason"], "mode": self.item_information.get("mode"), "ids": self.item_information.get("ids"), - "tv_data": self.item_information.get("tv_data"), + "tv_data": tv_data, "poster": self.item_information.get("poster"), "stream_subtitles": source.streamSubtitles, } diff --git a/lib/gui/custom_dialogs.py b/lib/gui/custom_dialogs.py index dc0f4949..54e02dc6 100644 --- a/lib/gui/custom_dialogs.py +++ b/lib/gui/custom_dialogs.py @@ -133,6 +133,7 @@ def source_select(item_info: Dict[str, str], xml_file: str, sources: List[Torren def run_next_dialog(params): + playback_session_id = "" try: playlist_size = PLAYLIST.size() playlist_pos = PLAYLIST.getposition() if playlist_size > 0 else -1 @@ -143,6 +144,9 @@ def run_next_dialog(params): window = None try: item_information = json.loads(params["item_info"]) + playback_session_id = str( + item_information.get("playback_session_id") or "" + ) if playlist_size > 0 and playlist_pos >= 0 and playlist_pos < playlist_size - 1: try: next_item = PLAYLIST[playlist_pos + 1] @@ -167,11 +171,22 @@ def run_next_dialog(params): if action == "next_episode": # Let Kodi finish restoring VideoNav focus after the modal close - # animation before the player monitor consumes this handoff. + # animation before the owning player monitor consumes this handoff. sleep(200) - set_property("jacktook_next_dialog_action", "next_episode") - else: - clear_property("jacktook_next_dialog_action") + if playback_session_id: + set_property( + "jacktook_next_dialog_action", + json.dumps( + { + "action": "next_episode", + "session_id": playback_session_id, + } + ), + ) + else: + kodilog( + "[PLAYNEXT] Ignoring next action without playback session" + ) def run_resume_dialog(params): diff --git a/lib/player.py b/lib/player.py index ec3f2364..af3a7a3e 100644 --- a/lib/player.py +++ b/lib/player.py @@ -3,6 +3,7 @@ from json import loads from threading import Thread from typing import Optional +from uuid import uuid4 import xbmc from xbmc import getCondVisibility as get_visibility @@ -39,11 +40,14 @@ ) from lib.utils.player.utils import ( autoscrape_next_episode, + build_elementum_pack_next_playback, get_autoscrape_cache_key, get_autoscrape_results_cache_key, ) AUTOPLAY_CONTEXT_NEXT_EPISODE = 1 +ACTIVE_PLAYER_SESSION_PROPERTY = "jacktook_active_player_session" +PLAYNEXT_ACTION_PROPERTY = "jacktook_next_dialog_action" total_time_errors = ("0.0", "", 0.0, None) video_fullscreen_check = "Window.IsActive(fullscreenvideo)" @@ -78,11 +82,55 @@ def __init__(self, on_started=None, on_error=None): self._trakt_resume_applied = False self._trakt_resume_playback_observed = False self._trakt_playback_delete_attempted = False + self.playback_session_id = "" + self._was_superseded = False + + def _activate_playback_session(self): + self.playback_session_id = uuid4().hex + self.data["playback_session_id"] = self.playback_session_id + set_property(ACTIVE_PLAYER_SESSION_PROPERTY, self.playback_session_id) + clear_property(PLAYNEXT_ACTION_PROPERTY) + kodilog( + f"[PLAYER] Activated playback session {self.playback_session_id[:8]}" + ) + + def _owns_playback_session(self) -> bool: + return bool(self.playback_session_id) and ( + get_property(ACTIVE_PLAYER_SESSION_PROPERTY) + == self.playback_session_id + ) + + def _consume_next_dialog_action(self) -> bool: + raw_action = get_property(PLAYNEXT_ACTION_PROPERTY) + if not raw_action: + return False + try: + payload = loads(raw_action) + except (TypeError, ValueError): + kodilog( + f"[PLAYNEXT] Ignoring unscoped action payload: {raw_action!r}" + ) + return False + if not isinstance(payload, dict): + return False + if payload.get("action") != "next_episode": + return False + if payload.get("session_id") != self.playback_session_id: + return False + clear_property(PLAYNEXT_ACTION_PROPERTY) + return True + + def _release_playback_session(self): + if not self._owns_playback_session(): + return + clear_property(PLAYNEXT_ACTION_PROPERTY) + clear_property(ACTIVE_PLAYER_SESSION_PROPERTY) def run(self, data=None): if data is None: data = {} self.set_constants(data) + self._activate_playback_session() self.clear_playback_properties() if not self._is_trakt_tracking_excluded(): self.add_external_trakt_scrolling() @@ -101,6 +149,12 @@ def run(self, data=None): except Exception as e: self.run_error(e) + if self._was_superseded: + kodilog( + "[PLAYER] run() superseded; skipping PlayNext queue drain" + ) + return + had_nextep = self._drain_nextep_queue() kodilog(f"[PLAYER] _drain_nextep_queue returned had_nextep={had_nextep}") kodilog("[PLAYER] run() completed") @@ -124,9 +178,14 @@ def _drain_nextep_queue(self) -> bool: ) if _auto_play_enabled() or results is None: - # Autoplay or resolved data: play directly (video is already stopped) + # This is an internal handoff after the previous plugin action + # has already finished resolving. setResolvedUrl() no longer has + # a pending Kodi request to answer, so mark the new playback for + # an explicit Player.play() call. + handoff_data = dict(data) + handoff_data["direct_playback_handoff"] = True player = JacktookPLayer() - player.run(data=data) + player.run(data=handoff_data) del player else: # Manual: show source select (safe context — no video playing) @@ -169,12 +228,20 @@ def play_video(self, list_item): self.handle_subtitles(list_item) if self.url.startswith(("http://", "https://", "file://")): list_item.setPath(self.url) - # Signal Kodi that the plugin action is a playback (required to - # avoid spinners when run() returns after video stops). - setResolvedUrl(ADDON_HANDLE, True, list_item) - # setResolvedUrl starts both direct streams and external plugin - # URLs. Calling Player().play() afterwards opens the stream twice. - kodilog("[PLAYER] play_video: calling setResolvedUrl only") + if self.data.get("direct_playback_handoff"): + # The original plugin resolution has already completed. Start + # the queued external-plugin URL explicitly instead of trying + # to resolve a stale addon handle. + kodilog( + "[PLAYER] play_video: calling Player.play for internal " + "PlayNext handoff" + ) + self.play(self.url, list_item) + else: + # Normal plugin entry point: answer Kodi's pending resolution. + # Calling Player.play() here as well would open the stream twice. + setResolvedUrl(ADDON_HANDLE, True, list_item) + kodilog("[PLAYER] play_video: calling setResolvedUrl only") self.monitor() kodilog("[PLAYER] play_video: monitor() returned") except Exception as e: @@ -306,6 +373,12 @@ def monitor(self): try: while not self.isPlayingVideo(): + if not self._owns_playback_session(): + self._was_superseded = True + kodilog( + "[PLAYER] monitor superseded while waiting for playback" + ) + return if self.kodi_monitor.abortRequested(): kodilog("[PLAYER] monitor: abort requested while waiting for video to start") return @@ -334,6 +407,12 @@ def monitor(self): # Monitor loop while self.isPlayingVideo(): + if not self._owns_playback_session(): + self._was_superseded = True + kodilog( + "[PLAYER] monitor superseded by a newer playback session" + ) + return self.update_playback_progress() self.handle_trakt_pause_resume() self.check_autoscrape_threshold() @@ -347,7 +426,7 @@ def monitor(self): self.check_stinger_notification() # Handle pending next-episode action from dialog - if get_property("jacktook_next_dialog_action") == "next_episode": + if self._consume_next_dialog_action(): self._handle_next_dialog_action() sleep(1000) @@ -358,8 +437,16 @@ def monitor(self): except Exception: self.kill_dialog() finally: - kodilog("[PLAYER] monitor: finally block - clearing playback properties") - self.clear_playback_properties() + if self._owns_playback_session(): + kodilog( + "[PLAYER] monitor: owner cleanup for playback session" + ) + self.clear_playback_properties() + self._release_playback_session() + else: + kodilog( + "[PLAYER] monitor: superseded session; skipping global cleanup" + ) kodilog("[PLAYER] monitor: exiting") def handle_subtitles(self, list_item): @@ -626,8 +713,23 @@ def _check_still_watching_threshold(self) -> bool: set_property("jacktook_consecutive_autoplays", str(count)) return False + def _queue_elementum_pack_next(self, next_tv_data: dict) -> bool: + """Queue the next episode from the currently selected Elementum pack.""" + next_data = build_elementum_pack_next_playback(self.data, next_tv_data) + if not next_data: + return False + + JacktookPLayer._nextep_queue.append({"data": next_data}) + kodilog( + f"[PLAYNEXT] Queued same Elementum pack for " + f"S{next_tv_data.get('season')}E{next_tv_data.get('episode')}" + ) + self.stop() + clear_property("jacktook_next_dialog_action") + return True + def _queue_from_autoscrape_cache(self, next_tv_data: dict, ids: dict) -> bool: - """Check autoscrape cache and if hit, queue next episode + STOP player.""" + """Queue resolved autoplay data or raw results for manual selection.""" id_value = ids.get("original_id") or ids.get("imdb_id") or ids.get("tmdb_id") if id_value is None: return False @@ -635,50 +737,51 @@ def _queue_from_autoscrape_cache(self, next_tv_data: dict, ids: dict) -> bool: cache_key = get_autoscrape_cache_key( id_value, next_tv_data.get("season"), next_tv_data.get("episode") ) - cached_data = cache.get(cache_key) - if not cached_data: - kodilog(f"[PLAYNEXT] Autoscrape cache MISS for key={cache_key}") - return False + results_cache_key = get_autoscrape_results_cache_key( + id_value, next_tv_data.get("season"), next_tv_data.get("episode") + ) - kodilog(f"[PLAYNEXT] Autoscrape cache HIT for key={cache_key}") from lib.utils.kodi.settings import auto_play_enabled if auto_play_enabled(): + cached_data = cache.get(cache_key) + if not cached_data: + kodilog(f"[PLAYNEXT] Autoscrape resolved cache MISS for key={cache_key}") + return False + entry_data = dict(cached_data) entry_data["autoplay"] = True entry_data["playnext_context"] = True JacktookPLayer._nextep_queue.append({"data": entry_data}) kodilog("[PLAYNEXT] Queued autoplay from cache, stopping player") - self.stop() - clear_property("jacktook_next_dialog_action") - return True - - # Autoplay disabled: queue cached raw results for source select - results_cache_key = get_autoscrape_results_cache_key( - id_value, next_tv_data.get("season"), next_tv_data.get("episode") - ) - cached_results = cache.get(results_cache_key) - - entry_data = { - "mode": self.data.get("mode", "tv"), - "ids": ids, - "tv_data": next_tv_data, - "return_tv_data": self.data.get("tv_data", {}), - "query": self.data.get("title", ""), - "media_type": self.data.get("media_type", ""), - "playnext_context": True, - } - JacktookPLayer._nextep_queue.append( - { - "data": entry_data, - "results": cached_results, + else: + cached_results = cache.get(results_cache_key) + if not cached_results: + kodilog( + f"[PLAYNEXT] Autoscrape results cache MISS for key={results_cache_key}" + ) + return False + + entry_data = { + "mode": self.data.get("mode", "tv"), + "ids": ids, + "tv_data": next_tv_data, + "return_tv_data": self.data.get("tv_data", {}), + "query": self.data.get("title", ""), + "media_type": self.data.get("media_type", ""), + "playnext_context": True, } - ) - kodilog( - f"[PLAYNEXT] Queued source select from cache" - f" (results: {len(cached_results) if cached_results else 0})," - f" stopping player" - ) + JacktookPLayer._nextep_queue.append( + { + "data": entry_data, + "results": cached_results, + } + ) + kodilog( + f"[PLAYNEXT] Queued manual source select from cache " + f"({len(cached_results)} sources), stopping player" + ) + self.stop() clear_property("jacktook_next_dialog_action") return True @@ -707,6 +810,11 @@ def _handle_next_dialog_action(self): ids = self.data.get("ids", {}) + # An explicit PlayNext click may reuse the exact pack selected by the + # user. This is intentionally independent from global autoplay. + if self._queue_elementum_pack_next(next_tv_data): + return + # Prefer the logical PlayNext queue paths over Kodi PLAYLIST advance. # PLAYLIST fallback is intentionally not consulted before next_tv_data. if self._queue_from_autoscrape_cache(next_tv_data, ids): @@ -729,7 +837,7 @@ def _background_search_and_queue(self, item_data: dict, next_tv_data: dict) -> N try: kodilog("[PLAYNEXT] Background search starting (silent)") - autoscrape_next_episode(item_data, next_tv_data) + autoscrape_next_episode(item_data, next_tv_data, force=True) kodilog("[PLAYNEXT] Background search complete, checking cache") ids = item_data.get("ids", {}) @@ -1084,6 +1192,7 @@ def set_constants(self, data): self._simkl_resume_applied = False self._simkl_resume_playback_observed = False self._simkl_playback_delete_attempted = False + self._was_superseded = False from lib.utils.general.utils import extract_release_group self.preferred_group = extract_release_group(self.data.get("title", "")) diff --git a/lib/utils/parsers/pack_parser.py b/lib/utils/parsers/pack_parser.py new file mode 100644 index 00000000..a9138f68 --- /dev/null +++ b/lib/utils/parsers/pack_parser.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import re +import unicodedata +from typing import Any, Dict, Iterable, Optional + +_PACK_TYPES = { + "season", + "multi_season", + "complete_unknown", + "unknown", + "episode", + "none", + "mismatch", +} + +_EPISODE_PATTERNS = ( + re.compile(r"(? str: + normalized = unicodedata.normalize("NFKD", str(title or "")) + normalized = normalized.encode("ascii", "ignore").decode("ascii") + normalized = normalized.upper().replace("_", " ").replace(".", " ") + return re.sub(r"\s+", " ", normalized).strip() + + +def _positive_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + number = int(str(value).strip()) + except (TypeError, ValueError): + return None + return number if number > 0 else None + + +def _scope( + is_pack: bool, + pack_type: str, + seasons: Iterable[int] = (), + reason: str = "", +) -> Dict[str, Any]: + normalized_type = pack_type if pack_type in _PACK_TYPES else "unknown" + normalized_seasons = sorted( + {season for season in (_positive_int(value) for value in seasons) if season is not None} + ) + return { + "is_pack": bool(is_pack), + "pack_type": normalized_type, + "pack_seasons": normalized_seasons, + "pack_reason": reason, + } + + +def detect_pack_scope( + title: Any, + current_season: Any = None, + source_is_pack: bool = False, +) -> Dict[str, Any]: + """Infer whether a TV release is an episode, season pack, or series pack. + + Explicit episode markers always win. Cross-season reuse is only possible + later when the title explicitly lists a season range or multiple seasons. + """ + normalized = _normalize_title(title) + current = _positive_int(current_season) + + for pattern in _EPISODE_PATTERNS: + if pattern.search(normalized): + return _scope(False, "episode", reason="explicit episode marker") + + seasons = set() + for pattern in _RANGE_PATTERNS: + match = pattern.search(normalized) + if not match: + continue + start = _positive_int(match.group(1)) + end = _positive_int(match.group(2)) + if start is None or end is None or start > end or end - start > 99: + continue + seasons.update(range(start, end + 1)) + + seasons.update( + int(value) + for value in re.findall(r"(? 0 + ) + seasons.update( + int(value) + for value in re.findall(r"\b(?:SEASON|SAISON)\s*0*(\d{1,2})\b", normalized) + if int(value) > 0 + ) + + if seasons: + if current is not None and current not in seasons: + return _scope(False, "mismatch", seasons, "explicit seasons exclude current season") + if len(seasons) == 1: + return _scope(True, "season", seasons, "single explicit season without episode") + return _scope(True, "multi_season", seasons, "explicit season range or list") + + if any(pattern.search(normalized) for pattern in _COMPLETE_PATTERNS): + return _scope(True, "complete_unknown", reason="complete-series wording without range") + + if source_is_pack: + return _scope(True, "unknown", reason="provider marked source as pack") + + return _scope(False, "none", reason="no pack evidence") + + +def playback_pack_scope(data: Dict[str, Any], current_season: Any = None) -> Dict[str, Any]: + """Return normalized pack metadata from playback data, with title fallback.""" + stored_type = str(data.get("pack_type") or "") + stored_seasons = data.get("pack_seasons") + if stored_type in _PACK_TYPES and bool(data.get("is_pack")): + if not isinstance(stored_seasons, (list, tuple, set)): + stored_seasons = [] + return _scope( + True, + stored_type, + stored_seasons, + str(data.get("pack_reason") or "stored playback metadata"), + ) + + return detect_pack_scope( + data.get("source_title") or "", + current_season=current_season, + source_is_pack=bool(data.get("is_pack")), + ) + + +def pack_scope_allows_transition( + scope: Dict[str, Any], + current_season: Any, + next_season: Any, +) -> bool: + """Return whether the same pack safely covers the requested transition.""" + current = _positive_int(current_season) + target = _positive_int(next_season) + if current is None or target is None or not scope.get("is_pack"): + return False + + if current == target: + return scope.get("pack_type") in { + "season", + "multi_season", + "complete_unknown", + "unknown", + } + + if scope.get("pack_type") != "multi_season": + return False + + seasons = { + season + for season in (_positive_int(value) for value in scope.get("pack_seasons", [])) + if season is not None + } + return current in seasons and target in seasons diff --git a/lib/utils/player/utils.py b/lib/utils/player/utils.py index 8668f784..c374133d 100644 --- a/lib/utils/player/utils.py +++ b/lib/utils/player/utils.py @@ -1,6 +1,6 @@ from datetime import timedelta from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import quote +from urllib.parse import parse_qs, quote, urlparse from xbmc import LOGDEBUG from xbmcgui import Dialog @@ -21,6 +21,10 @@ torrent_clients, ) from lib.utils.kodi.logging import summarize_locator_for_log +from lib.utils.parsers.pack_parser import ( + pack_scope_allows_transition, + playback_pack_scope, +) from lib.utils.kodi.utils import ( execute_builtin, get_setting, @@ -189,6 +193,87 @@ def get_elementum_url( ) +def build_elementum_pack_next_playback( + data: Dict[str, Any], next_tv_data: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + """Build resolved PlayNext data when the selected Elementum pack covers it.""" + if data.get("mode") != "tv" or not isinstance(next_tv_data, dict): + return None + + ids = data.get("ids") + current_tv_data = data.get("tv_data") + if not isinstance(ids, dict) or not isinstance(current_tv_data, dict): + return None + + tmdb_id = ids.get("tmdb_id") + current_season = current_tv_data.get("season") + next_season = next_tv_data.get("season") + next_episode = next_tv_data.get("episode") + values = (tmdb_id, current_season, next_season, next_episode) + if not all(not isinstance(value, bool) and str(value).isdigit() for value in values): + return None + + pack_scope = playback_pack_scope(data, current_season=current_season) + if not pack_scope.get("is_pack"): + kodilog( + f"[PLAYNEXT] Elementum pack reuse rejected: " + f"type={pack_scope.get('pack_type')}, reason={pack_scope.get('pack_reason')}" + ) + return None + if not pack_scope_allows_transition(pack_scope, current_season, next_season): + kodilog( + f"[PLAYNEXT] Elementum pack reuse rejected for season transition " + f"S{current_season}->S{next_season}: type={pack_scope.get('pack_type')}, " + f"seasons={pack_scope.get('pack_seasons')}" + ) + return None + + current_url = str(data.get("url") or "") + parsed = urlparse(current_url) + if ( + parsed.scheme != "plugin" + or parsed.netloc != "plugin.video.elementum" + or parsed.path.rstrip("/") != "/play" + ): + return None + + uri_values = parse_qs(parsed.query).get("uri") or [] + uri = str(uri_values[0]) if uri_values else str(data.get("magnet") or "") + if not uri: + return None + + next_data = dict(data) + next_data["tv_data"] = dict(next_tv_data) + next_data["playnext_context"] = True + next_data["is_pack"] = True + next_data["pack_type"] = pack_scope["pack_type"] + next_data["pack_seasons"] = pack_scope["pack_seasons"] + next_data["pack_reason"] = pack_scope["pack_reason"] + for key in ( + "autoplay", + "current_time", + "direct_play", + "next_tv_data", + "progress", + "resume", + "subtitles_path", + "stream_subtitles", + "total_time", + ): + next_data.pop(key, None) + + magnet = uri if uri.startswith("magnet:") else "" + torrent_url = "" if magnet else uri + next_data["url"] = get_elementum_url( + magnet, + torrent_url, + "tv", + ids, + data=next_data, + ) + return next_data + + def get_jacktorr_url(magnet: str, url: str, data: Optional[Dict[str, Any]] = None) -> Optional[str]: kodilog( f"Preparing Jacktorr URL with magnet={summarize_locator_for_log(magnet)!r}, url={summarize_locator_for_log(url)!r}, has_magnet={bool(magnet)}, has_url={bool(url)}", @@ -348,9 +433,17 @@ def _extract_quality_from_titles(results: List[TorrentStream]) -> None: res.quality = _UNKNOWN_QUALITY_LABEL -def autoscrape_next_episode(item_data: Dict[str, Any], next_tv_data: Dict[str, Any]) -> None: - """Background thread: search, select, resolve, and cache next episode.""" - if not get_setting("autoscrape_next_episode", False): +def autoscrape_next_episode( + item_data: Dict[str, Any], + next_tv_data: Dict[str, Any], + force: bool = False, +) -> None: + """Background thread: search and cache the next episode. + + ``force`` is used after an explicit PlayNext click. It bypasses only the + proactive pre-scrape setting; it does not enable global autoplay. + """ + if not force and not get_setting("autoscrape_next_episode", False): return ids = item_data.get("ids") @@ -370,9 +463,9 @@ def autoscrape_next_episode(item_data: Dict[str, Any], next_tv_data: Dict[str, A results_cache_key = get_autoscrape_results_cache_key(id_value, season, episode) try: - from lib.search import search_client + from lib.search import _process_search_results, search_client - results = search_client( + raw_results = search_client( query=item_data.get("title", ""), ids=ids, mode=item_data.get("mode", ""), @@ -383,17 +476,35 @@ def autoscrape_next_episode(item_data: Dict[str, Any], next_tv_data: Dict[str, A show_dialog=False, ) - if not results: + if not raw_results: kodilog("Autoscrape: no results found") return - # Populate quality from title for each result (search_client - # returns raw TorrentStream objects with quality="N/A"). This - # matches what PreProcessBuilder.filter_by_quality() does without - # importing Kodi-dependent modules in this background thread. - _extract_quality_from_titles(results) + if not get_setting("auto_play", False): + results = _process_search_results( + raw_results, + item_data.get("mode", ""), + next_tv_data.get("name", ""), + episode, + season, + "", + item_data.get("title", ""), + item_data.get("media_type", ""), + True, + suppress_debrid_dialog=True, + ) + if not results: + kodilog("Autoscrape: no results after normal source processing") + return + cache_autoscrape_result(results_cache_key, results) + kodilog( + f"Autoscrape: cached processed results {results_cache_key} " + f"({len(results)} sources)" + ) + return - # Cache results for source select (when autoplay is disabled) + results = raw_results + _extract_quality_from_titles(results) cache_autoscrape_result(results_cache_key, results) kodilog(f"Autoscrape: cached results {results_cache_key} ({len(results)} sources)") diff --git a/tests/unit/test_autoscrape.py b/tests/unit/test_autoscrape.py index 89c9990a..4494ba04 100644 --- a/tests/unit/test_autoscrape.py +++ b/tests/unit/test_autoscrape.py @@ -388,20 +388,27 @@ def test_run_next_dialog_sets_pending_action_without_direct_playback(): "title": "Show", "ids": {"tmdb_id": "123"}, "tv_data": {"season": 1, "episode": 1}, + "playback_session_id": "owner-session", } ) } run_next_dialog(params) - mock_set_property.assert_called_once_with("jacktook_next_dialog_action", "next_episode") + mock_set_property.assert_called_once() + key, raw_payload = mock_set_property.call_args.args + assert key == "jacktook_next_dialog_action" + assert json.loads(raw_payload) == { + "action": "next_episode", + "session_id": "owner-session", + } mock_clear_property.assert_not_called() mock_xbmc_player.assert_not_called() created_item_info = mock_window_cls.call_args.kwargs["item_information"] assert created_item_info["next_label"] == "1x2. Next Episode" -def test_run_next_dialog_clears_pending_action_when_dialog_not_accepted(): +def test_run_next_dialog_does_not_publish_action_when_dialog_not_accepted(): from lib.gui.custom_dialogs import run_next_dialog with patch("lib.gui.custom_dialogs.PLAYLIST") as mock_playlist, patch( @@ -429,7 +436,7 @@ def test_run_next_dialog_clears_pending_action_when_dialog_not_accepted(): run_next_dialog(params) mock_set_property.assert_not_called() - mock_clear_property.assert_called_once_with("jacktook_next_dialog_action") + mock_clear_property.assert_not_called() def test_run_next_dialog_shows_without_next_playlist_item(): @@ -446,10 +453,22 @@ def test_run_next_dialog_shows_without_next_playlist_item(): mock_window.action = "next_episode" mock_window_cls.return_value = mock_window - run_next_dialog({"item_info": "{}"}) + run_next_dialog( + { + "item_info": json.dumps( + {"playback_session_id": "owner-session"} + ) + } + ) mock_window_cls.assert_called_once() created_item_info = mock_window_cls.call_args.kwargs["item_information"] assert "next_label" not in created_item_info - mock_set_property.assert_called_once_with("jacktook_next_dialog_action", "next_episode") + mock_set_property.assert_called_once() + key, raw_payload = mock_set_property.call_args.args + assert key == "jacktook_next_dialog_action" + assert json.loads(raw_payload) == { + "action": "next_episode", + "session_id": "owner-session", + } mock_clear_property.assert_not_called() diff --git a/tests/unit/test_elementum_pack_playnext.py b/tests/unit/test_elementum_pack_playnext.py new file mode 100644 index 00000000..703c088b --- /dev/null +++ b/tests/unit/test_elementum_pack_playnext.py @@ -0,0 +1,409 @@ +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, quote, urlparse + +import pytest + + +@pytest.fixture(autouse=True) +def fresh_player_module(monkeypatch): + import lib + import xbmc + + class FakeKodiPlayer: + def __init__(self, *args, **kwargs): + pass + + original_player_module = sys.modules.get("lib.player") + + monkeypatch.setattr(xbmc, "Player", FakeKodiPlayer) + sys.modules.pop("lib.player", None) + if hasattr(lib, "player"): + delattr(lib, "player") + + try: + yield + finally: + sys.modules.pop("lib.player", None) + if hasattr(lib, "player"): + delattr(lib, "player") + + if original_player_module is not None: + sys.modules["lib.player"] = original_player_module + setattr(lib, "player", original_player_module) + + +def _query(url): + return parse_qs(urlparse(url).query) + + +def _current_pack_data(magnet="magnet:?xt=urn:btih:PACKHASH"): + return { + "mode": "tv", + "is_pack": True, + "ids": {"tmdb_id": "37606", "imdb_id": "tt1942683"}, + "tv_data": {"season": 5, "episode": 3, "name": "L'Ami"}, + "url": ( + "plugin://plugin.video.elementum/play" + f"?uri={quote(magnet)}&type=tv&tmdb=37606" + "&show=37606&season=5&episode=3" + ), + "magnet": magnet, + "progress": 95.0, + "current_time": 1200, + "total_time": 1260, + "subtitles_path": ["current.srt"], + } + + +def test_source_pack_marker_is_preserved_in_playback_data(): + from lib.gui.base_window import BaseWindow + + class TestWindow(BaseWindow): + def handle_action(self, action_id, control_id=None): + pass + + window = object.__new__(TestWindow) + window.item_information = { + "mode": "tv", + "ids": {"tmdb_id": "37606"}, + "tv_data": {"season": 5, "episode": 3}, + } + source = SimpleNamespace( + type="torrent", + indexer="Jackett", + infoHash="PACKHASH", + title="Season pack", + isPack=True, + streamSubtitles=[], + stremioMetadata={}, + debridType="", + ) + + data = window.prepare_source_data( + source, + url="", + magnet="magnet:?xt=urn:btih:PACKHASH", + is_torrent=True, + ) + + assert data["is_pack"] is True + + +def test_build_elementum_pack_next_playback_reuses_same_uri(): + from lib.utils.player import utils + + current = _current_pack_data() + next_tv_data = {"season": 5, "episode": 4, "name": "L'Ennui"} + + with patch.object(utils, "is_elementum_addon", return_value=True): + result = utils.build_elementum_pack_next_playback(current, next_tv_data) + + assert result is not None + query = _query(result["url"]) + assert query["uri"] == [current["magnet"]] + assert query["show"] == ["37606"] + assert query["season"] == ["5"] + assert query["episode"] == ["4"] + assert result["tv_data"] == next_tv_data + assert result["playnext_context"] is True + assert "autoplay" not in result + assert "progress" not in result + assert "subtitles_path" not in result + + +@pytest.mark.parametrize( + "mutation,next_tv_data", + [ + ({"is_pack": False}, {"season": 5, "episode": 4}), + ({"url": "plugin://plugin.video.other/play"}, {"season": 5, "episode": 4}), + ({}, {"season": 6, "episode": 1}), + ], +) +def test_build_elementum_pack_next_playback_rejects_unsafe_reuse(mutation, next_tv_data): + from lib.utils.player import utils + + current = _current_pack_data() + current.update(mutation) + + with patch.object(utils, "is_elementum_addon", return_value=True): + assert utils.build_elementum_pack_next_playback(current, next_tv_data) is None + + +def test_explicit_pack_playnext_is_queued_without_global_autoplay(monkeypatch): + from lib.player import JacktookPLayer + + player = object.__new__(JacktookPLayer) + player.data = _current_pack_data() + player.stop = MagicMock() + clear_property = MagicMock() + next_data = { + "url": "plugin://plugin.video.elementum/play?episode=4", + "mode": "tv", + "tv_data": {"season": 5, "episode": 4}, + } + monkeypatch.setattr( + "lib.player.build_elementum_pack_next_playback", + lambda *_args, **_kwargs: next_data, + ) + monkeypatch.setattr("lib.player.clear_property", clear_property) + JacktookPLayer._nextep_queue.clear() + + try: + assert player._queue_elementum_pack_next({"season": 5, "episode": 4}) is True + assert JacktookPLayer._nextep_queue == [{"data": next_data}] + finally: + JacktookPLayer._nextep_queue.clear() + + assert "autoplay" not in next_data + player.stop.assert_called_once_with() + clear_property.assert_called_once_with("jacktook_next_dialog_action") + + +def test_handle_next_dialog_prefers_same_pack_before_cache(monkeypatch): + from lib.player import JacktookPLayer + + player = object.__new__(JacktookPLayer) + player.data = _current_pack_data() + player._check_still_watching_threshold = MagicMock(return_value=False) + player._authoritative_next_tv_data = MagicMock( + return_value={"season": 5, "episode": 4} + ) + player._queue_elementum_pack_next = MagicMock(return_value=True) + player._queue_from_autoscrape_cache = MagicMock(return_value=True) + + player._handle_next_dialog_action() + + player._queue_elementum_pack_next.assert_called_once_with( + {"season": 5, "episode": 4} + ) + player._queue_from_autoscrape_cache.assert_not_called() + + +def test_forced_search_keeps_manual_source_selection(monkeypatch): + from lib.utils.player import utils + + source = MagicMock() + source.title = "Show S05E04 1080p" + source.quality = "N/A" + search_client = MagicMock(return_value=[source]) + cache_set = MagicMock() + resolve = MagicMock() + + def get_setting(key, default=None): + return { + "autoscrape_next_episode": False, + "auto_play": False, + "autoscrape_ttl": 4, + }.get(key, default) + + monkeypatch.setattr("lib.search.search_client", search_client) + monkeypatch.setattr( + "lib.search._process_search_results", + lambda results, *_args, **_kwargs: results, + ) + monkeypatch.setattr("lib.utils.player.utils.get_setting", get_setting) + monkeypatch.setattr("lib.utils.player.utils.cache.set", cache_set) + monkeypatch.setattr("lib.utils.player.utils.resolve_playback_url", resolve) + + utils.autoscrape_next_episode( + { + "mode": "tv", + "title": "Show", + "ids": {"imdb_id": "tt1942683", "tmdb_id": "37606"}, + }, + {"season": 5, "episode": 4}, + force=True, + ) + + search_client.assert_called_once() + resolve.assert_not_called() + cache_set.assert_called_once() + assert cache_set.call_args.args[0] == "as_results:tt1942683_5_4" + assert cache_set.call_args.args[1] == [source] + + +def test_manual_cache_path_does_not_require_resolved_autoplay_data(monkeypatch): + from lib.player import JacktookPLayer + + player = object.__new__(JacktookPLayer) + player.data = { + "mode": "tv", + "title": "Show", + "media_type": "tv", + "tv_data": {"season": 5, "episode": 3}, + } + player.stop = MagicMock() + results = [MagicMock()] + clear_property = MagicMock() + + def cache_get(key): + if key == "as_results:tt1942683_5_4": + return results + return None + + monkeypatch.setattr("lib.player.cache.get", cache_get) + monkeypatch.setattr( + "lib.utils.kodi.settings.auto_play_enabled", lambda: False + ) + monkeypatch.setattr("lib.player.clear_property", clear_property) + JacktookPLayer._nextep_queue.clear() + + try: + assert player._queue_from_autoscrape_cache( + {"season": 5, "episode": 4}, + {"imdb_id": "tt1942683"}, + ) is True + entry = JacktookPLayer._nextep_queue[0] + assert entry["results"] == results + assert entry["data"]["tv_data"] == {"season": 5, "episode": 4} + finally: + JacktookPLayer._nextep_queue.clear() + + player.stop.assert_called_once_with() + clear_property.assert_called_once_with("jacktook_next_dialog_action") + + + +def test_season_pack_is_inferred_when_provider_flag_is_missing(): + from lib.gui.base_window import BaseWindow + + class TestWindow(BaseWindow): + def handle_action(self, action_id, control_id=None): + pass + + window = object.__new__(TestWindow) + window.item_information = { + "mode": "tv", + "ids": {"tmdb_id": "37606"}, + "tv_data": {"season": 5, "episode": 3}, + } + source = SimpleNamespace( + type="torrent", + indexer="Prowlarr", + infoHash="PACKHASH", + title="Le monde incroyable de Gumball s05 vff webrip aac -llam", + isPack=False, + streamSubtitles=[], + stremioMetadata={}, + debridType="", + ) + + data = window.prepare_source_data( + source, + url="", + magnet="magnet:?xt=urn:btih:PACKHASH", + is_torrent=True, + ) + + assert data["is_pack"] is True + assert data["pack_type"] == "season" + assert data["pack_seasons"] == [5] + assert data["source_title"] == source.title + + +def test_explicit_multi_season_pack_reuses_uri_across_boundary(): + from lib.utils.player import utils + + current = _current_pack_data() + current.update( + { + "source_title": "Show.S01-S06.Complete.1080p", + "pack_type": "multi_season", + "pack_seasons": [1, 2, 3, 4, 5, 6], + "tv_data": {"season": 5, "episode": 50}, + } + ) + + with patch.object(utils, "is_elementum_addon", return_value=True): + result = utils.build_elementum_pack_next_playback( + current, {"season": 6, "episode": 1, "name": "Season premiere"} + ) + + assert result is not None + query = _query(result["url"]) + assert query["uri"] == [current["magnet"]] + assert query["season"] == ["6"] + assert query["episode"] == ["1"] + + +def test_single_season_pack_does_not_cross_boundary(): + from lib.utils.player import utils + + current = _current_pack_data() + current.update( + { + "source_title": "Show.S05.1080p", + "pack_type": "season", + "pack_seasons": [5], + "tv_data": {"season": 5, "episode": 50}, + } + ) + + with patch.object(utils, "is_elementum_addon", return_value=True): + result = utils.build_elementum_pack_next_playback( + current, {"season": 6, "episode": 1} + ) + + assert result is None + + +def test_complete_series_without_explicit_range_does_not_cross_boundary(): + from lib.utils.player import utils + + current = _current_pack_data() + current.update( + { + "source_title": "Show.Complete.Series.1080p", + "pack_type": "complete_unknown", + "pack_seasons": [], + "tv_data": {"season": 5, "episode": 50}, + } + ) + + with patch.object(utils, "is_elementum_addon", return_value=True): + result = utils.build_elementum_pack_next_playback( + current, {"season": 6, "episode": 1} + ) + + assert result is None + + +def test_forced_manual_search_caches_normal_processed_results(monkeypatch): + from lib.utils.player import utils + + raw_results = [MagicMock(title="raw")] + processed_results = [MagicMock(title="processed")] + search_client = MagicMock(return_value=raw_results) + process_results = MagicMock(return_value=processed_results) + cache_set = MagicMock() + + def get_setting(key, default=None): + return { + "autoscrape_next_episode": False, + "auto_play": False, + "autoscrape_ttl": 4, + }.get(key, default) + + monkeypatch.setattr("lib.search.search_client", search_client) + monkeypatch.setattr("lib.search._process_search_results", process_results) + monkeypatch.setattr("lib.utils.player.utils.get_setting", get_setting) + monkeypatch.setattr("lib.utils.player.utils.cache.set", cache_set) + + utils.autoscrape_next_episode( + { + "mode": "tv", + "media_type": "tv", + "title": "Show", + "ids": {"imdb_id": "tt1942683", "tmdb_id": "37606"}, + }, + {"season": 5, "episode": 4, "name": "Next episode"}, + force=True, + ) + + search_client.assert_called_once() + process_results.assert_called_once() + cache_set.assert_called_once() + assert cache_set.call_args.args[0] == "as_results:tt1942683_5_4" + assert cache_set.call_args.args[1] == processed_results diff --git a/tests/unit/test_pack_parser.py b/tests/unit/test_pack_parser.py new file mode 100644 index 00000000..cc0d840b --- /dev/null +++ b/tests/unit/test_pack_parser.py @@ -0,0 +1,82 @@ +import pytest + +from lib.utils.parsers.pack_parser import ( + detect_pack_scope, + pack_scope_allows_transition, +) + + +@pytest.mark.parametrize( + "title", + [ + "Show.S05E03.1080p.WEB-DL", + "Show 5x03 1080p", + "Show Season 5 Episode 3", + ], +) +def test_explicit_episode_is_never_inferred_as_pack(title): + scope = detect_pack_scope(title, current_season=5, source_is_pack=True) + + assert scope["is_pack"] is False + assert scope["pack_type"] == "episode" + + +def test_single_season_title_is_inferred_as_season_pack(): + scope = detect_pack_scope( + "Le monde incroyable de Gumball s05 vff webrip aac -llam", + current_season=5, + ) + + assert scope["is_pack"] is True + assert scope["pack_type"] == "season" + assert scope["pack_seasons"] == [5] + + +@pytest.mark.parametrize( + "title,expected", + [ + ("Show.S01-S06.Complete.1080p", [1, 2, 3, 4, 5, 6]), + ("Show Seasons 1-6 Complete", [1, 2, 3, 4, 5, 6]), + ("Show Saisons 1 a 6 Integrale", [1, 2, 3, 4, 5, 6]), + ("Show.S01.S02.S03.MULTi", [1, 2, 3]), + ], +) +def test_explicit_multi_season_titles_record_covered_seasons(title, expected): + scope = detect_pack_scope(title, current_season=2) + + assert scope["is_pack"] is True + assert scope["pack_type"] == "multi_season" + assert scope["pack_seasons"] == expected + + +@pytest.mark.parametrize( + "title", + [ + "Show Complete Series 1080p", + "Show Full Series", + "Show Integrale MULTi", + "Show Serie Complete", + ], +) +def test_unbounded_complete_series_is_conservative(title): + scope = detect_pack_scope(title, current_season=5) + + assert scope["is_pack"] is True + assert scope["pack_type"] == "complete_unknown" + assert scope["pack_seasons"] == [] + assert pack_scope_allows_transition(scope, 5, 5) is True + assert pack_scope_allows_transition(scope, 5, 6) is False + + +def test_explicit_multi_season_pack_can_cross_only_inside_range(): + scope = detect_pack_scope("Show.S01-S06.Complete", current_season=5) + + assert pack_scope_allows_transition(scope, 5, 6) is True + assert pack_scope_allows_transition(scope, 6, 7) is False + + +def test_explicit_seasons_excluding_current_are_rejected(): + scope = detect_pack_scope("Show.S01-S04.Complete", current_season=5, source_is_pack=True) + + assert scope["is_pack"] is False + assert scope["pack_type"] == "mismatch" diff --git a/tests/unit/test_playnext_behavior.py b/tests/unit/test_playnext_behavior.py index 66f08c94..3beaa5c7 100644 --- a/tests/unit/test_playnext_behavior.py +++ b/tests/unit/test_playnext_behavior.py @@ -1,3 +1,4 @@ +import json import sys from types import ModuleType from unittest.mock import MagicMock, call @@ -59,13 +60,22 @@ def doModal(self): lambda key, value: events.append(("set_property", key, value)), ) - custom_dialogs.run_next_dialog({"item_info": "{}"}) + custom_dialogs.run_next_dialog( + { + "item_info": json.dumps( + {"playback_session_id": "owner-session"} + ) + } + ) - assert events == [ - "modal_closed", - ("sleep", 200), - ("set_property", "jacktook_next_dialog_action", "next_episode"), - ] + assert events[:2] == ["modal_closed", ("sleep", 200)] + event, key, raw_payload = events[2] + assert event == "set_property" + assert key == "jacktook_next_dialog_action" + assert json.loads(raw_payload) == { + "action": "next_episode", + "session_id": "owner-session", + } def test_handle_next_dialog_action_ignores_playlist_advance_and_uses_handler(monkeypatch): diff --git a/tests/unit/test_playnext_internal_handoff.py b/tests/unit/test_playnext_internal_handoff.py new file mode 100644 index 00000000..dd7403cf --- /dev/null +++ b/tests/unit/test_playnext_internal_handoff.py @@ -0,0 +1,116 @@ +import sys +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def fresh_player_module(monkeypatch): + import lib + import xbmc + + class FakeKodiPlayer: + def __init__(self, *args, **kwargs): + pass + + original_player_module = sys.modules.get("lib.player") + + monkeypatch.setattr(xbmc, "Player", FakeKodiPlayer) + sys.modules.pop("lib.player", None) + if hasattr(lib, "player"): + delattr(lib, "player") + + try: + yield + finally: + sys.modules.pop("lib.player", None) + if hasattr(lib, "player"): + delattr(lib, "player") + + if original_player_module is not None: + sys.modules["lib.player"] = original_player_module + setattr(lib, "player", original_player_module) + + +def _play_video_player(): + from lib.player import JacktookPLayer + + player = object.__new__(JacktookPLayer) + player.url = "plugin://plugin.video.elementum/play?episode=4" + player.data = {} + player._check_volume = MagicMock(return_value=True) + player.cancel_playback = MagicMock() + player._handle_trakt_scrobble = MagicMock() + player.handle_subtitles = MagicMock() + player.monitor = MagicMock() + player.run_error = MagicMock() + player.play = MagicMock() + return player + + +def test_internal_playnext_handoff_uses_player_play(monkeypatch): + player = _play_video_player() + player.data["direct_playback_handoff"] = True + list_item = MagicMock() + set_resolved_url = MagicMock() + + monkeypatch.setattr("lib.player.close_busy_dialog", lambda: None) + monkeypatch.setattr("lib.player.setResolvedUrl", set_resolved_url) + + player.play_video(list_item) + + player.play.assert_called_once_with(player.url, list_item) + set_resolved_url.assert_not_called() + player.monitor.assert_called_once_with() + + +def test_normal_plugin_playback_keeps_setresolvedurl(monkeypatch): + from lib.player import ADDON_HANDLE + + player = _play_video_player() + list_item = MagicMock() + set_resolved_url = MagicMock() + + monkeypatch.setattr("lib.player.close_busy_dialog", lambda: None) + monkeypatch.setattr("lib.player.setResolvedUrl", set_resolved_url) + + player.play_video(list_item) + + set_resolved_url.assert_called_once_with(ADDON_HANDLE, True, list_item) + player.play.assert_not_called() + player.monitor.assert_called_once_with() + + +def test_queue_drain_marks_resolved_data_as_internal_handoff(monkeypatch): + import lib.player as player_module + + original_player_class = player_module.JacktookPLayer + original_data = { + "url": "plugin://plugin.video.elementum/play?episode=4", + "title": "Show", + "mode": "tv", + } + + class FakeNextPlayer: + _nextep_queue = [{"data": original_data, "results": None}] + created = [] + + def __init__(self): + self.run = MagicMock() + self.__class__.created.append(self) + + owner = object.__new__(original_player_class) + owner.PLAYLIST = MagicMock() + + monkeypatch.setattr(player_module, "JacktookPLayer", FakeNextPlayer) + monkeypatch.setattr( + "lib.utils.kodi.settings.auto_play_enabled", lambda: False + ) + + assert original_player_class._drain_nextep_queue(owner) is True + + assert len(FakeNextPlayer.created) == 1 + handoff_data = FakeNextPlayer.created[0].run.call_args.kwargs["data"] + assert handoff_data["direct_playback_handoff"] is True + assert handoff_data["url"] == original_data["url"] + assert "direct_playback_handoff" not in original_data diff --git a/tests/unit/test_playnext_session_guard.py b/tests/unit/test_playnext_session_guard.py new file mode 100644 index 00000000..6ffb54cb --- /dev/null +++ b/tests/unit/test_playnext_session_guard.py @@ -0,0 +1,137 @@ +import json +import sys +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def fresh_player_module(monkeypatch): + import lib + import xbmc + + class FakeKodiPlayer: + def __init__(self, *args, **kwargs): + pass + + original_player_module = sys.modules.get("lib.player") + + monkeypatch.setattr(xbmc, "Player", FakeKodiPlayer) + sys.modules.pop("lib.player", None) + if hasattr(lib, "player"): + delattr(lib, "player") + + try: + yield + finally: + sys.modules.pop("lib.player", None) + if hasattr(lib, "player"): + delattr(lib, "player") + + if original_player_module is not None: + sys.modules["lib.player"] = original_player_module + setattr(lib, "player", original_player_module) + + +def test_matching_session_consumes_next_action(monkeypatch): + from lib.player import JacktookPLayer + + player = object.__new__(JacktookPLayer) + player.playback_session_id = "owner-session" + clear_property = MagicMock() + + monkeypatch.setattr( + "lib.player.get_property", + lambda key: json.dumps( + {"action": "next_episode", "session_id": "owner-session"} + ), + ) + monkeypatch.setattr("lib.player.clear_property", clear_property) + + assert player._consume_next_dialog_action() is True + clear_property.assert_called_once_with("jacktook_next_dialog_action") + + +def test_non_owner_does_not_consume_next_action(monkeypatch): + from lib.player import JacktookPLayer + + player = object.__new__(JacktookPLayer) + player.playback_session_id = "old-session" + clear_property = MagicMock() + + monkeypatch.setattr( + "lib.player.get_property", + lambda key: json.dumps( + {"action": "next_episode", "session_id": "current-session"} + ), + ) + monkeypatch.setattr("lib.player.clear_property", clear_property) + + assert player._consume_next_dialog_action() is False + clear_property.assert_not_called() + + +def test_superseded_run_does_not_drain_shared_queue(monkeypatch): + from lib.player import JacktookPLayer + + player = object.__new__(JacktookPLayer) + player.PLAYLIST = MagicMock() + player.clear_playback_properties = MagicMock() + player._activate_playback_session = MagicMock() + player._is_trakt_tracking_excluded = MagicMock(return_value=True) + player.mark_watched = MagicMock() + player._drain_nextep_queue = MagicMock() + + def set_constants(data): + player.data = data + player.url = data["url"] + player._was_superseded = False + + def play_video(_list_item): + player._was_superseded = True + + player.set_constants = set_constants + player.play_video = play_video + + monkeypatch.setattr("lib.player.close_busy_dialog", lambda: None) + monkeypatch.setattr("lib.player.make_listing", lambda data: MagicMock()) + + player.run({"url": "plugin://plugin.video.elementum/play", "mode": "tv"}) + + player._drain_nextep_queue.assert_not_called() + + +def test_dialog_handoff_contains_playback_session(monkeypatch): + from lib.gui import custom_dialogs + + class FakeWindow: + action = "next_episode" + + def __init__(self, *args, **kwargs): + pass + + def doModal(self): + pass + + custom_dialogs.PLAYLIST = MagicMock() + custom_dialogs.PLAYLIST.size.return_value = 0 + set_property = MagicMock() + + monkeypatch.setattr(custom_dialogs, "PlayNext", FakeWindow) + monkeypatch.setattr(custom_dialogs, "sleep", lambda milliseconds: None) + monkeypatch.setattr(custom_dialogs, "set_property", set_property) + + custom_dialogs.run_next_dialog( + { + "item_info": json.dumps( + {"playback_session_id": "owner-session"} + ) + } + ) + + key, raw_payload = set_property.call_args.args + assert key == "jacktook_next_dialog_action" + assert json.loads(raw_payload) == { + "action": "next_episode", + "session_id": "owner-session", + } diff --git a/tests/unit/test_stremio_playback_fidelity.py b/tests/unit/test_stremio_playback_fidelity.py index 36618167..84b01ad5 100644 --- a/tests/unit/test_stremio_playback_fidelity.py +++ b/tests/unit/test_stremio_playback_fidelity.py @@ -1663,7 +1663,7 @@ def test_serialized_file_index_uses_existing_route_without_index_handoff(monkeyp monkeypatch.setattr( player_utils, "get_elementum_url", - lambda magnet, url, mode, ids: resolver_calls.append((magnet, url, mode, ids)) or "plugin://elementum/play", + lambda magnet, url, mode, ids, data=None: resolver_calls.append((magnet, url, mode, ids)) or "plugin://elementum/play", ) resolved = stremio_playback.resolve_stremio_playback_url(data) From e91e4076edf6588f3c143de316bd5e0f84c38cdb Mon Sep 17 00:00:00 2001 From: sammax Date: Sun, 9 Aug 2026 08:24:07 +0200 Subject: [PATCH 2/2] fix(playnext): suppress background dialog cleanup --- lib/search.py | 6 +++- lib/utils/player/utils.py | 1 + tests/unit/test_elementum_pack_playnext.py | 4 +++ tests/unit/test_search.py | 35 ++++++++++++++++++++++ tests/unit/test_simkl_continue_watching.py | 2 +- 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/search.py b/lib/search.py index c97f5051..00985cef 100644 --- a/lib/search.py +++ b/lib/search.py @@ -604,6 +604,7 @@ def _process_search_results( media_type, rescrape, suppress_debrid_dialog=False, + suppress_busy_dialog=False, ): bypassed_streams = [] other_results = results @@ -643,6 +644,7 @@ def is_bypassed(result): rescrape, episode, suppress_dialog=suppress_debrid_dialog, + suppress_busy_dialog=suppress_busy_dialog, ) # Combine results, prioritizing bypassed streams exact native sorting @@ -1555,11 +1557,13 @@ def process_results( rescrape: bool, episode: int, suppress_dialog: bool = False, + suppress_busy_dialog: bool = False, ) -> List[TorrentStream]: torrent_results = [] if get_setting("torrent_enable"): torrent_results = post_process(pre_results) - close_busy_dialog() + if not suppress_busy_dialog: + close_busy_dialog() if suppress_dialog: debrid_results = check_debrid_cached( query, pre_results, mode, media_type, SilentProgressDialog(), rescrape, episode diff --git a/lib/utils/player/utils.py b/lib/utils/player/utils.py index c374133d..eef654cf 100644 --- a/lib/utils/player/utils.py +++ b/lib/utils/player/utils.py @@ -492,6 +492,7 @@ def autoscrape_next_episode( item_data.get("media_type", ""), True, suppress_debrid_dialog=True, + suppress_busy_dialog=force, ) if not results: kodilog("Autoscrape: no results after normal source processing") diff --git a/tests/unit/test_elementum_pack_playnext.py b/tests/unit/test_elementum_pack_playnext.py index 703c088b..a6cb6981 100644 --- a/tests/unit/test_elementum_pack_playnext.py +++ b/tests/unit/test_elementum_pack_playnext.py @@ -404,6 +404,10 @@ def get_setting(key, default=None): search_client.assert_called_once() process_results.assert_called_once() + assert process_results.call_args.kwargs == { + "suppress_debrid_dialog": True, + "suppress_busy_dialog": True, + } cache_set.assert_called_once() assert cache_set.call_args.args[0] == "as_results:tt1942683_5_4" assert cache_set.call_args.args[1] == processed_results diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py index 0e520c19..b01609e1 100644 --- a/tests/unit/test_search.py +++ b/tests/unit/test_search.py @@ -24,6 +24,41 @@ def test_search_variant_values(): assert SearchVariant.ORIGINAL_TITLE_YEAR == "original_title_year" +def test_process_results_suppresses_busy_dialog_when_requested(): + from lib import search + + close_busy_dialog = MagicMock() + silent_dialog = MagicMock() + check_debrid_cached = MagicMock(return_value=[]) + with patch.object(search, "get_setting", return_value=False), patch.object( + search, "close_busy_dialog", close_busy_dialog + ), patch.object(search, "SilentProgressDialog", return_value=silent_dialog), patch.object( + search, "check_debrid_cached", check_debrid_cached + ): + assert search.process_results( + [MagicMock()], "Show", "tv", "tv", True, 2, + suppress_dialog=True, + suppress_busy_dialog=True, + ) == [] + + close_busy_dialog.assert_not_called() + assert check_debrid_cached.call_args.args[4] is silent_dialog + + +def test_process_results_closes_busy_dialog_by_default(): + from lib import search + + close_busy_dialog = MagicMock() + with patch.object(search, "get_setting", return_value=False), patch.object( + search, "close_busy_dialog", close_busy_dialog + ), patch.object(search, "SilentProgressDialog"), patch.object( + search, "check_debrid_cached", return_value=[] + ): + search.process_results([MagicMock()], "Show", "tv", "tv", True, 2, suppress_dialog=True) + + close_busy_dialog.assert_called_once_with() + + def test_super_quick_play_preserves_simkl_resume_metadata(): params = { "ids": json.dumps({"tmdb_id": 123}), diff --git a/tests/unit/test_simkl_continue_watching.py b/tests/unit/test_simkl_continue_watching.py index 77ca184a..b205a8a2 100644 --- a/tests/unit/test_simkl_continue_watching.py +++ b/tests/unit/test_simkl_continue_watching.py @@ -41,7 +41,7 @@ def test_simkl_library_is_in_tv_and_movie_menus_not_root_menu(): assert simkl_items[0]["name"] != 90981 assert len(simkl_menu_entries) == 1 assert simkl_menu_entries[0]["name"] == simkl_items[0]["name"] - assert simkl_items[0]["icon"] == "library.png" + assert simkl_items[0]["icon"] == "simkl.png" assert simkl_items[0]["params"] == {"media_type": media_type} assert simkl_items[0]["condition"] is has_simkl_library_items assert items[items.index(simkl_items[0]) + 1]["params"]["group"] == "library"