OverviewFilesCommitsIssuesDocsReleasesPeers

kodi-jacktook commits / 430f1c5

Route adaptive manifests through inputstream.adaptive (1.15.4)
Jacktook never engaged ISA for stream playback -- its only ISA call was the mpd trailer path -- so every HLS stream landed on Kodi's bare ffmpeg demuxer. That demuxer picks one variant and never offers a choice, which is why quality had to be chosen from the source list before playback rather than while watching. With ISA driving the stream the player OSD gains a video-stream picker, and ISA reads the variant playlists (which carry EXT-X-ENDLIST) so a master no longer reads as a live stream that refuses to pause. set_adaptive_stream() engages ISA for .m3u8/.mpd and returns the URL to play. Detection strips both the query string and the `|key=value` suffix first: our own stream URLs carry ?h=auto, so matching the bare tail would miss every one. Headers move to ISA's own manifest_headers/stream_headers properties, and the `|` suffix is removed from the played URL. Those are two different mechanisms and do not compose -- handing the suffixed URL to ISA is how the previous attempt ended up fetching the variant playlist bare and taking a 403 from the CDN (org labs #2). That CDN no longer gates on headers at all, re-measured 2026-08-10, which is what makes this worth attempting again. Anything that is not an adaptive manifest is left exactly as it was: torrent, debrid and plugin:// URLs never reach ISA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
430f1c58583295306aa791360c057cc7243d3de1
by plankatron <fleeky@gmail.com> · 2026-08-10T22:00:48.000Z

Changed files (4) +158 -1

plugin.video.jacktook/addon.xml +1 -1
……@@ -1,5 +1,5 @@
11<?xml version="1.0" encoding="utf-8"?>
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.15.3" provider-name="Sam-Max">
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.15.4" provider-name="Sam-Max">
33 <requires>
44 <import addon="xbmc.python" version="3.00.0"/>
55 <import addon="script.module.requests" version="2.25.1"/>
plugin.video.jacktook/lib/player.py +8 -0
……@@ -39,6 +39,7 @@ from lib.utils.player.utils import (
3939 autoscrape_next_episode,
4040 get_autoscrape_cache_key,
4141 get_autoscrape_results_cache_key,
42 set_adaptive_stream,
4243)
4344
4445AUTOPLAY_CONTEXT_NEXT_EPISODE = 1
……@@ -85,6 +86,13 @@ class JacktookPLayer(xbmc.Player):
8586
8687 try:
8788 self.list_item = make_listing(data)
89 # An .m3u8/.mpd goes through inputstream.adaptive, which is what
90 # gives the player OSD a video-stream picker. It also returns the
91 # URL without the `|header` suffix, since ISA takes headers via its
92 # own properties instead.
93 adaptive_url = set_adaptive_stream(self.list_item, self.data)
94 if adaptive_url:
95 self.url = adaptive_url
8896 self.PLAYLIST.add(self.url, self.list_item)
8997 self.play_video(self.list_item)
9098 except Exception as e:
plugin.video.jacktook/lib/utils/player/utils.py +60 -0
……@@ -52,6 +52,66 @@ def append_proxy_headers(url: str, headers: Dict[str, str]) -> str:
5252 return f"{url}|{encoded}" if encoded else url
5353
5454
55ADAPTIVE_MANIFESTS = {".m3u8": "hls", ".mpd": "mpd"}
56ADAPTIVE_MIMETYPES = {"hls": "application/vnd.apple.mpegurl", "mpd": "application/dash+xml"}
57
58
59def adaptive_manifest_type(url: str) -> Optional[str]:
60 """"hls" / "mpd" if this URL is an adaptive manifest, else None.
61
62 The `|key=value` header suffix and the query string are both stripped first:
63 our own stream URLs carry `?h=auto`, so matching the bare tail would miss
64 every one of them.
65 """
66 path = url.split("|", 1)[0].split("?", 1)[0].lower()
67 for ext, kind in ADAPTIVE_MANIFESTS.items():
68 if path.endswith(ext):
69 return kind
70 return None
71
72
73def set_adaptive_stream(list_item, data: Dict[str, Any]) -> Optional[str]:
74 """Route an adaptive manifest through inputstream.adaptive.
75
76 Kodi only offers a video-stream picker in the player OSD when ISA is driving
77 playback; on the bare ffmpeg demuxer it picks one variant and never offers a
78 choice. Engaging ISA is therefore what puts quality selection *during*
79 playback rather than in the source list. ISA also reads the variant
80 playlists, which carry EXT-X-ENDLIST, so a master no longer reads as a live
81 stream that refuses to pause.
82
83 Returns the URL to play (the `|header` suffix removed), or None if this is
84 not an adaptive manifest and nothing was changed.
85
86 Headers move to ISA's own properties rather than the `|` suffix, because
87 they are two different mechanisms and do not compose -- passing the suffixed
88 URL to ISA is how the previous attempt ended up fetching the variant
89 playlist bare and taking a 403 from the CDN.
90 """
91 url = data.get("url") or ""
92 kind = adaptive_manifest_type(url)
93 if not kind:
94 return None
95
96 list_item.setProperty("inputstream", "inputstream.adaptive")
97 list_item.setProperty("inputstream.adaptive.manifest_type", kind)
98
99 headers = data.get("proxyHeaders") or {}
100 encoded = "&".join(
101 f"{key}={quote(str(value), safe='')}"
102 for key, value in headers.items()
103 if key and value
104 )
105 if encoded:
106 list_item.setProperty("inputstream.adaptive.manifest_headers", encoded)
107 list_item.setProperty("inputstream.adaptive.stream_headers", encoded)
108
109 list_item.setMimeType(ADAPTIVE_MIMETYPES[kind])
110 list_item.setContentLookup(False)
111 kodilog(f"[PLAYER] inputstream.adaptive engaged ({kind}), headers={bool(encoded)}")
112 return url.split("|", 1)[0]
113
114
55115def resolve_playback_url(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
56116 indexer_type: str = data.get("type", "")
57117 debrid_type: str = data.get("debrid_type", "")
plugin.video.jacktook/tests/unit/test_adaptive_stream.py +89 -0
……@@ -0,0 +1,89 @@
1from unittest.mock import MagicMock
2
3from lib.utils.player.utils import adaptive_manifest_type, set_adaptive_stream
4
5
6# Kodi only offers a video-stream picker in the player OSD when
7# inputstream.adaptive is driving playback -- on the bare ffmpeg demuxer it
8# picks one variant and never offers a choice. These cover engaging ISA for
9# adaptive manifests without disturbing every other kind of source.
10
11
12def test_detects_hls_and_dash():
13 assert adaptive_manifest_type("https://h/x.m3u8") == "hls"
14 assert adaptive_manifest_type("https://h/x.mpd") == "mpd"
15
16
17def test_detection_ignores_the_query_string():
18 """Our own stream URLs carry ?h=auto, so a bare-tail match misses them."""
19 assert adaptive_manifest_type("http://h:7220/vidcore/series/tmdb:1:2:7.m3u8?h=auto") == "hls"
20
21
22def test_detection_ignores_the_header_suffix():
23 url = "http://h/x.m3u8|Referer=https%3A%2F%2Fvidcore.net%2F"
24 assert adaptive_manifest_type(url) == "hls"
25
26
27def test_non_manifests_are_left_alone():
28 for url in ("https://h/video.mp4", "plugin://plugin.video.elementum/play", "magnet:?xt=1", ""):
29 assert adaptive_manifest_type(url) is None
30
31
32def test_engages_isa_for_a_manifest():
33 item = MagicMock()
34 url = set_adaptive_stream(item, {"url": "http://h:7220/a.m3u8?h=auto"})
35 assert url == "http://h:7220/a.m3u8?h=auto"
36 props = dict(c.args for c in item.setProperty.call_args_list)
37 assert props["inputstream"] == "inputstream.adaptive"
38 assert props["inputstream.adaptive.manifest_type"] == "hls"
39
40
41def test_leaves_other_sources_untouched():
42 """A torrent/debrid/plugin URL must not be handed to ISA."""
43 item = MagicMock()
44 assert set_adaptive_stream(item, {"url": "https://h/movie.mkv"}) is None
45 item.setProperty.assert_not_called()
46
47
48def test_strips_the_header_suffix_from_the_played_url():
49 """Kodi's `|k=v` suffix and ISA's header properties are different
50 mechanisms and do not compose; handing the suffixed URL to ISA is how the
51 previous attempt fetched the variant playlist bare and took a 403."""
52 item = MagicMock()
53 url = set_adaptive_stream(
54 item, {"url": "http://h/a.m3u8|Referer=https%3A%2F%2Fvidcore.net%2F"}
55 )
56 assert url == "http://h/a.m3u8"
57 assert "|" not in url
58
59
60def test_headers_move_to_isa_properties():
61 item = MagicMock()
62 set_adaptive_stream(
63 item,
64 {
65 "url": "http://h/a.m3u8",
66 "proxyHeaders": {"Referer": "https://vidcore.net/", "User-Agent": "UA/1.0"},
67 },
68 )
69 props = dict(c.args for c in item.setProperty.call_args_list)
70 for key in ("inputstream.adaptive.manifest_headers", "inputstream.adaptive.stream_headers"):
71 assert "Referer=https%3A%2F%2Fvidcore.net%2F" in props[key]
72 assert "User-Agent=UA%2F1.0" in props[key]
73
74
75def test_no_header_properties_when_there_are_none():
76 item = MagicMock()
77 set_adaptive_stream(item, {"url": "http://h/a.m3u8"})
78 props = dict(c.args for c in item.setProperty.call_args_list)
79 assert "inputstream.adaptive.manifest_headers" not in props
80
81
82def test_mimetype_is_set_per_manifest_kind():
83 for url, mime in (
84 ("http://h/a.m3u8", "application/vnd.apple.mpegurl"),
85 ("http://h/a.mpd", "application/dash+xml"),
86 ):
87 item = MagicMock()
88 set_adaptive_stream(item, {"url": url})
89 item.setMimeType.assert_called_once_with(mime)