| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | <?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"> | |
| 3 | 3 | <requires> |
| 4 | 4 | <import addon="xbmc.python" version="3.00.0"/> |
| 5 | 5 | <import addon="script.module.requests" version="2.25.1"/> |
| … | … | @@ -39,6 +39,7 @@ from lib.utils.player.utils import ( |
| 39 | 39 | autoscrape_next_episode, |
| 40 | 40 | get_autoscrape_cache_key, |
| 41 | 41 | get_autoscrape_results_cache_key, |
| 42 | set_adaptive_stream, | |
| 42 | 43 | ) |
| 43 | 44 | |
| 44 | 45 | AUTOPLAY_CONTEXT_NEXT_EPISODE = 1 |
| … | … | @@ -85,6 +86,13 @@ class JacktookPLayer(xbmc.Player): |
| 85 | 86 | |
| 86 | 87 | try: |
| 87 | 88 | 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 | |
| 88 | 96 | self.PLAYLIST.add(self.url, self.list_item) |
| 89 | 97 | self.play_video(self.list_item) |
| 90 | 98 | except Exception as e: |
| … | … | @@ -52,6 +52,66 @@ def append_proxy_headers(url: str, headers: Dict[str, str]) -> str: |
| 52 | 52 | return f"{url}|{encoded}" if encoded else url |
| 53 | 53 | |
| 54 | 54 | |
| 55 | ADAPTIVE_MANIFESTS = {".m3u8": "hls", ".mpd": "mpd"} | |
| 56 | ADAPTIVE_MIMETYPES = {"hls": "application/vnd.apple.mpegurl", "mpd": "application/dash+xml"} | |
| 57 | ||
| 58 | ||
| 59 | def 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 | ||
| 73 | def 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 | ||
| 55 | 115 | def resolve_playback_url(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: |
| 56 | 116 | indexer_type: str = data.get("type", "") |
| 57 | 117 | debrid_type: str = data.get("debrid_type", "") |
| … | … | @@ -0,0 +1,89 @@ |
| 1 | from unittest.mock import MagicMock | |
| 2 | ||
| 3 | from 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 | ||
| 12 | def 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 | ||
| 17 | def 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 | ||
| 22 | def 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 | ||
| 27 | def 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 | ||
| 32 | def 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 | ||
| 41 | def 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 | ||
| 48 | def 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 | ||
| 60 | def 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 | ||
| 75 | def 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 | ||
| 82 | def 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) |