Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/gui/resolver_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def __init__(
close_callback: Optional[Any] = None,
is_subtitle_download: bool = False,
local_subtitle_path: Optional[str] = None,
direct_playback_handoff: bool = False,
) -> None:
super().__init__(
xml_file,
Expand All @@ -42,6 +43,8 @@ def __init__(
self.pack_select: bool = False
self.is_subtitle_download = is_subtitle_download
self.local_subtitle_path = local_subtitle_path
self.direct_playback_handoff = direct_playback_handoff
self.playback_resolution_attempted = False
self.item_information: Dict = item_information or {}
self.close_callback: Optional[Any] = close_callback
self.playback_info: Optional[Dict[str, Any]] = None
Expand Down Expand Up @@ -102,6 +105,15 @@ def resolve_source(self) -> Optional[Dict[str, Any]]:
if not self.playback_info:
raise Exception("Failed to resolve source")

if self.direct_playback_handoff:
self.playback_info["direct_playback_handoff"] = True
kodilog(
"[RESOLVER] Original Kodi resolution already consumed; "
"using explicit Player.play()"
)

self.playback_resolution_attempted = True

player = JacktookPLayer(
on_started=self.handle_playback_started,
on_error=self.handle_playback_error,
Expand Down
31 changes: 29 additions & 2 deletions lib/gui/source_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ def __init__(
self.filtered_sources: Optional[List[TorrentStream]] = None
self.filter_applied: bool = False
self.resolved = False
# Kodi's original plugin resolution handle can only be consumed once.
# If a playback attempt fails but SourceSelect stays open, subsequent
# attempts must start playback explicitly with Player.play().
self._kodi_resolution_consumed = False

def onInit(self) -> None:
theme_index = get_setting("source_select_theme", "0")
Expand Down Expand Up @@ -644,6 +648,10 @@ def _resolve_item(
local_subtitle_path: Optional[str] = None,
) -> None:
self.setProperty("resolving", "true")
kodi_resolution_consumed = bool(
getattr(self, "_kodi_resolution_consumed", False)
)

resolver_window = ResolverWindow(
"resolver.xml",
ADDON_PATH,
Expand All @@ -652,9 +660,28 @@ def _resolve_item(
item_information=self.item_information,
is_subtitle_download=is_subtitle_download,
local_subtitle_path=local_subtitle_path,
direct_playback_handoff=kodi_resolution_consumed,
)
self.resolved = resolver_window.doModal(pack_select)
del resolver_window
try:
self.resolved = resolver_window.doModal(pack_select)

playback_resolution_attempted = (
getattr(
resolver_window,
"playback_resolution_attempted",
False,
)
is True
)

self._kodi_resolution_consumed = (
kodi_resolution_consumed
or playback_resolution_attempted
)
finally:
if not self.resolved:
self.setProperty("resolving", "false")
del resolver_window

def show_resume_dialog(self, playback_percent: float) -> Optional[bool]:
try:
Expand Down
77 changes: 75 additions & 2 deletions lib/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

AUTOPLAY_CONTEXT_NEXT_EPISODE = 1
ACTIVE_PLAYER_SESSION_PROPERTY = "jacktook_active_player_session"
ELEMENTUM_RESOLUTION_STATUS_PREFIX = "plugin.video.elementum.resolution_status."
PLAYNEXT_ACTION_PROPERTY = "jacktook_next_dialog_action"
total_time_errors = ("0.0", "", 0.0, None)
video_fullscreen_check = "Window.IsActive(fullscreenvideo)"
Expand Down Expand Up @@ -84,6 +85,7 @@ def __init__(self, on_started=None, on_error=None):
self._trakt_playback_delete_attempted = False
self.playback_session_id = ""
self._was_superseded = False
self._playback_error_detected = False

def _activate_playback_session(self):
self.playback_session_id = uuid4().hex
Expand All @@ -92,6 +94,52 @@ def _activate_playback_session(self):
clear_property(PLAYNEXT_ACTION_PROPERTY)
kodilog(f"[PLAYER] Activated playback session {self.playback_session_id[:8]}")

def _scope_elementum_resolution_signal(self):
if not self.url or not self.url.startswith(
"plugin://plugin.video.elementum/play"
):
return

session_id = getattr(self, "playback_session_id", "")
if not session_id:
kodilog("[PLAYER] Elementum resolution signal has no playback session")
return

# Each playback gets its own failure property so late cleanup from an
# older Elementum player cannot overwrite a newer attempt.
failure_property = (
f"{ELEMENTUM_RESOLUTION_STATUS_PREFIX}{session_id}"
)
clear_property(failure_property)

# A reused Elementum playback URL may already contain an older session
# marker. Replace it rather than accumulating markers across handoffs.
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

parsed = urlsplit(self.url)
query = [
(key, value)
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
if key != "resolution_token"
]
query.append(("resolution_token", session_id))

self.url = urlunsplit(
(
parsed.scheme,
parsed.netloc,
parsed.path,
urlencode(query),
parsed.fragment,
)
)
self.data["url"] = self.url

kodilog(
f"[PLAYER] Scoped Elementum resolution to "
f"{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
Expand Down Expand Up @@ -126,6 +174,7 @@ def run(self, data=None):
data = {}
self.set_constants(data)
self._activate_playback_session()
self._scope_elementum_resolution_signal()
self.clear_playback_properties()
if not self._is_trakt_tracking_excluded():
self.add_external_trakt_scrolling()
Expand Down Expand Up @@ -225,7 +274,7 @@ def play_video(self, list_item):
# 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")
kodilog("[PLAYER] play_video: calling Player.play for explicit playback handoff")
self.play(self.url, list_item)
else:
# Normal plugin entry point: answer Kodi's pending resolution.
Expand Down Expand Up @@ -358,12 +407,32 @@ def handle_trakt_pause_resume(self):

self._playback_was_paused = is_paused

def onPlayBackError(self):
self._playback_error_detected = True
kodilog("[PLAYER] Kodi reported playback error while resolving")

def monitor(self):
ensure_dialog_closed = False
kodilog("[PLAYER] monitor() entered")

try:
while not self.isPlayingVideo():
elementum_failure_property = (
f"{ELEMENTUM_RESOLUTION_STATUS_PREFIX}"
f"{self.playback_session_id}"
)
if get_property(elementum_failure_property) == "failed":
clear_property(elementum_failure_property)
kodilog(
"[PLAYER] Elementum reported failed/cancelled resolution"
)
self.handle_playback_failure()
return

if self._playback_error_detected:
kodilog("[PLAYER] monitor detected failed playback resolution")
self.handle_playback_failure()
return
if not self._owns_playback_session():
self._was_superseded = True
kodilog("[PLAYER] monitor superseded while waiting for playback")
Expand Down Expand Up @@ -523,9 +592,12 @@ def select_audio_stream(self):
break

def handle_playback_failure(self):
self.kill_dialog()
if self.on_error:
# ResolverWindow owns its UI lifecycle. Let its error callback close
# only the resolver so SourceSelect remains open underneath.
self.on_error()
else:
self.kill_dialog()
self.stop()

def handle_playback_start(self):
Expand Down Expand Up @@ -1172,6 +1244,7 @@ def set_constants(self, data):
self._simkl_resume_playback_observed = False
self._simkl_playback_delete_attempted = False
self._was_superseded = False
self._playback_error_detected = False
from lib.utils.general.utils import extract_release_group

self.preferred_group = extract_release_group(self.data.get("title", ""))
Expand Down
1 change: 1 addition & 0 deletions lib/utils/player/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def get_elementum_url(
f"&show={quote(str(tmdb_id))}"
f"&season={quote(str(season))}"
f"&episode={quote(str(episode))}"
"&skip_file_dialog=true"
)
elif mode in ("movie", "movies"):
file_match = _build_elementum_movie_file_match((data or {}).get("title"))
Expand Down
4 changes: 0 additions & 4 deletions resources/skins/Default/1080i/source_select.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
<texture>white.png</texture>
<colordiffuse>8000000</colordiffuse>
<aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
<animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
<visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
</control>

Expand All @@ -27,15 +26,13 @@
<texture background="true">$INFO[Window().Property(info.fanart)]</texture>
<colordiffuse>FFFFFFFF</colordiffuse>
<aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
<animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
<visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
</control>

<!-- Overlay -->
<control type="image">
<texture background="true">white.png</texture>
<colordiffuse>CC000000</colordiffuse>
<animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
<visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
</control>
</control>
Expand All @@ -47,7 +44,6 @@
<effect type="zoom" start="140,140" time="350" center="auto" tween="cubic" easing="in"/>
</animation>
<animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
<animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
<animation type="Conditional" condition="!String.IsEqual(Window().Property(resolving),true)">
<effect type="slide" start="841,401" time="350" delay="350" tween="cubic" easing="in"/>
<effect type="zoom" start="140,140" time="350" delay="350" center="auto" tween="cubic" easing="in"/>
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/test_elementum_skip_file_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse

import pytest


def _query(url: str):
return parse_qs(urlparse(url).query)


def test_elementum_exact_tv_episode_disables_manual_file_dialog():
from lib.utils.player import utils

with patch.object(utils, "is_elementum_addon", return_value=True):
url = utils.get_elementum_url(
"magnet:?xt=urn:btih:SEASONPACK",
"",
"tv",
{"tmdb_id": "2190"},
data={
"title": "A TV Show",
"tv_data": {"season": 2, "episode": 1},
},
)

query = _query(url)

assert query["show"] == ["2190"]
assert query["season"] == ["2"]
assert query["episode"] == ["1"]
assert query["skip_file_dialog"] == ["true"]


@pytest.mark.parametrize(
("ids", "tv_data"),
[
({"tmdb_id": "2190"}, {"season": 2}),
({"tmdb_id": "2190"}, {"season": 2, "episode": None}),
({}, {"season": 2, "episode": 1}),
],
)
def test_elementum_incomplete_tv_metadata_keeps_existing_fallback(ids, tv_data):
from lib.utils.player import utils

with patch.object(utils, "is_elementum_addon", return_value=True):
url = utils.get_elementum_url(
"magnet:?xt=urn:btih:EPISODE",
"",
"tv",
ids,
data={"title": "A TV Show", "tv_data": tv_data},
)

query = _query(url)

assert "skip_file_dialog" not in query
assert "show" not in query
assert "season" not in query
assert "episode" not in query


def test_elementum_movie_does_not_disable_manual_file_dialog():
from lib.utils.player import utils

with patch.object(utils, "is_elementum_addon", return_value=True):
url = utils.get_elementum_url(
"magnet:?xt=urn:btih:MOVIEPACK",
"",
"movies",
{"tmdb_id": "4271"},
data={
"title": "Mais où est donc passée la 7ème compagnie ?",
},
)

query = _query(url)

assert "file_match" in query
assert "skip_file_dialog" not in query
Loading