OverviewFilesCommitsIssuesDocsReleasesPeers

kodi-jacktook commits / 0e1a02b

Subtitles, from the source the stream's own player uses (1.16.0)
The streams we play carry none, and never did. Measured on No Other Choice (tmdb 639988): vidcore's HLS master lists four muxed A/V variants and no #EXT-X-MEDIA at all, so there was nothing in the stream for a player to find and nothing `-c copy` could have dropped. The 2026-07-21 deobfuscation of vidcore's bundle had already recorded where the player gets them instead -- `vidcore.net/wyzie`, a proxy in front of Wyzie -- and that note is what made this cheap. It is plain HTTP: no browser, no key (sub.wyzie.ru direct now answers `401 API key required`), one request. Every track is taken rather than a preferred language, because the cost of all of them is not what it looks like: measured against the live endpoint, all 32 tracks for that movie arrive in 2.9s / 3.6 MB at 8 workers, every one plain SRT. Sequential would be ~20s, past the point Kodi abandons opening a stream, so the parallelism is load-bearing rather than an optimisation. Two things fall out of the endpoint taking a tmdb OR an imdb id with no translation between them (verified: id=1396 and id=tt0903747 return byte-identical bodies): - It works for every source Jacktook plays, not just vidcore, so it goes in SubtitleManager beside the OpenSubtitles client rather than anywhere near the stremio stream parser. - A title with only a tmdb id now gets subtitles. The old path refused those outright ("no_imdb"), which is every title from the fxl:/tmdb catalogs. Not exclusive: a title Wyzie lacks falls through to OpenSubtitles, which is exactly when that source earns its place. Wyzie answers 404 with a zero-length body for those (verified on X-Men '97 S2E8, which genuinely has none while S1E1 has 34), so "no subtitles" is an ordinary outcome and never surfaces as an error to someone who was only trying to press play. Files are named from wyzie's own `display` because Kodi labels a subtitle by its filename, and `display` already separates 'Portuguese (BR)' from 'Portuguese' -- a distinction pb/pt only carries if we happen to know that mapping. handle_subtitles gated the whole SubtitleManager path on stremio_subtitle_enabled, so the new setting has to open that gate too or enabling it alone would do nothing at all. A test asserts that, because it is the kind of wiring that looks done and is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0e1a02b0bc56ff529f0d18c73f9a3958a5e49d61
by plankatron <fleeky@gmail.com> · 2026-09-05T00:41:56.000Z

Changed files (7) +563 -7

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.8" provider-name="Sam-Max">
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.16.0" 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/clients/subtitle/submanager.py +44 -5
……@@ -7,6 +7,7 @@ import xbmcgui
77
88from lib.clients.subtitle.deepl import DeepLTranslator
99from lib.clients.subtitle.opensubstremio import OpenSubtitleStremioClient
10from lib.clients.subtitle.wyzie import WyzieClient
1011from lib.utils.kodi.utils import (
1112 ADDON_PROFILE_PATH,
1213 get_setting,
……@@ -36,6 +37,7 @@ class SubtitleManager(KodiJsonRpcClient):
3637 self.data = data
3738 self.notification = notification
3839 self.opensub_client = OpenSubtitleStremioClient(notification)
40 self.wyzie_client = WyzieClient(notification)
3941 self.translator = DeepLTranslator(notification)
4042 self.last_fetch_status = None
4143
……@@ -76,21 +78,29 @@ class SubtitleManager(KodiJsonRpcClient):
7678 self.last_fetch_status = None
7779 title = title or self.data.get("title")
7880 mode = self.data.get("mode")
79 imdb_id = imdb_id or self.data.get("ids", {}).get("imdb_id")
81 ids = self.data.get("ids") or {}
82 imdb_id = imdb_id or ids.get("imdb_id")
8083 tv_data = self.data.get("tv_data", {})
8184 episode = tv_data.get("episode")
8285 season = tv_data.get("season")
8386
84 if not imdb_id:
85 kodilog("No IMDb ID found for the current video, skipping subtitles")
87 # Wyzie is keyed on a tmdb OR an imdb id with no translation between
88 # them (verified 2026-09-04: id=1396 and id=tt0903747 return
89 # byte-identical bodies), so a title from the tmdb-only catalogs is no
90 # longer refused outright the way the imdb-keyed path had to refuse it.
91 ident = imdb_id or ids.get("tmdb_id")
92
93 if not ident:
94 kodilog("No IMDb or TMDB ID found for the current video, skipping subtitles")
8695 self.last_fetch_status = "no_imdb"
8796 return None
8897
98 ident = str(ident)
8999 if folder_path is None:
90100 folder_path = (
91 os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id, str(season), str(episode))
101 os.path.join(ADDON_PROFILE_PATH, "Subtitles", ident, str(season), str(episode))
92102 if mode == "tv"
93 else os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id)
103 else os.path.join(ADDON_PROFILE_PATH, "Subtitles", ident)
94104 )
95105
96106 if not os.path.exists(folder_path):
……@@ -112,6 +122,35 @@ class SubtitleManager(KodiJsonRpcClient):
112122 if use_existing:
113123 return subtitle_files
114124
125 # Wyzie first: it is the source the vidcore player itself uses, needs no
126 # key, and returned 32 tracks for No Other Choice where OpenSubtitles'
127 # stremio addon needs an imdb id we may not have. Not exclusive -- a
128 # title it lacks is exactly when the other source earns its place.
129 if get_setting("wyzie_subtitle_enabled"):
130 tracks = self.wyzie_client.get_subtitles(mode, ident, season, episode)
131 if tracks:
132 wyzie_paths = self.wyzie_client.download_subtitles_batch(
133 tracks,
134 title=title,
135 season=season,
136 episode=episode,
137 folder_path=folder_path,
138 )
139 if wyzie_paths:
140 return wyzie_paths
141
142 if not get_setting("stremio_subtitle_enabled"):
143 self.last_fetch_status = "not_found"
144 self.notification(translation(90252))
145 return None
146
147 if not imdb_id:
148 # The OpenSubtitles stremio addon is imdb-keyed only, so a tmdb-only
149 # title reaches the end of the road here rather than at the top.
150 kodilog("No IMDb ID for OpenSubtitles, skipping")
151 self.last_fetch_status = "no_imdb"
152 return None
153
115154 subtitles = self.opensub_client.get_subtitles(
116155 mode, imdb_id, season, episode, auto_select=auto_select
117156 )
plugin.video.jacktook/lib/clients/subtitle/wyzie.py +165 -0
……@@ -0,0 +1,165 @@
1"""Subtitles from the endpoint the vidcore player itself uses.
2
3The streams Jacktook plays carry no subtitles of their own. Measured 2026-09-04
4on No Other Choice (tmdb 639988): the HLS master lists four muxed A/V variants
5and no #EXT-X-MEDIA at all, so there is nothing in the stream for a player to
6find. The 2026-07-21 deobfuscation of vidcore's bundle showed where the player
7gets them instead -- `vidcore.net/wyzie`, a proxy in front of Wyzie -- and that
8endpoint is plain HTTP, needs no browser, and needs no key of ours (sub.wyzie.ru
9direct now answers `401 API key required`).
10
11It is keyed on a tmdb OR imdb id with no translation between them (verified:
12`id=1396` and `id=tt0903747` return byte-identical bodies), which is what makes
13it usable for every source Jacktook plays rather than only for vidcore.
14
15Everything available is taken. Measured on the live endpoint: all 32 tracks for
16that movie in 2.9s / 3.6 MB at 8 workers, every one plain SRT. Filtering to one
17language would save nothing worth the loss of the player's own subtitle menu.
18"""
19
20import os
21from concurrent.futures import ThreadPoolExecutor
22from typing import Any, Callable, Dict, List, Optional
23
24import requests
25
26from lib.clients.subtitle.utils import slugify_title
27from lib.utils.general.utils import USER_AGENT_HEADER
28from lib.utils.kodi.utils import kodilog
29
30WYZIE_URL = "https://vidcore.net/wyzie"
31
32# The CDN behind this host 403s a request without a Referer, and the proxy sits
33# on the same host, so it is sent rather than discovered to be needed later.
34WYZIE_HEADERS = dict(USER_AGENT_HEADER, Referer="https://vidcore.net/")
35
36QUERY_TIMEOUT = 15
37DOWNLOAD_TIMEOUT = 25
38# Measured: 32 tracks in 2.9s at 8, slowest single request 0.95s. Sequential
39# would be ~20s, which is past the point Kodi abandons opening a stream.
40DOWNLOAD_WORKERS = 8
41
42
43class WyzieClient:
44 def __init__(self, notification: Callable[[str], None]):
45 self.notification = notification
46
47 def _build_url(
48 self, ident: str, season: Optional[int], episode: Optional[int]
49 ) -> str:
50 url = "{0}?id={1}".format(WYZIE_URL, ident)
51 if season and episode:
52 url += "&season={0}&episode={1}".format(season, episode)
53 return url
54
55 def get_subtitles(
56 self,
57 mode: str,
58 ident: str,
59 season: Optional[int] = None,
60 episode: Optional[int] = None,
61 ) -> List[Dict[str, Any]]:
62 """Available tracks, or [] -- never raises.
63
64 A title with no subtitles answers 404 with a zero-length body (verified
65 on X-Men '97 S2E8, which genuinely has none while S1E1 has 34). That is
66 an ordinary outcome and must not surface as an error to someone who was
67 only trying to press play.
68 """
69 url = self._build_url(ident, season, episode)
70 try:
71 response = requests.get(url, timeout=QUERY_TIMEOUT, headers=WYZIE_HEADERS)
72 if response.status_code != 200:
73 kodilog("Wyzie: no subtitles for {0} (HTTP {1})".format(
74 ident, response.status_code))
75 return []
76 data = response.json()
77 except Exception as e:
78 kodilog("Wyzie: subtitle lookup failed for {0}: {1}".format(ident, e))
79 return []
80
81 if not isinstance(data, list):
82 kodilog("Wyzie: unexpected answer shape for {0}".format(ident))
83 return []
84
85 tracks = [t for t in data if isinstance(t, dict) and t.get("url")]
86 kodilog("Wyzie: {0} subtitle track(s) for {1}".format(len(tracks), ident))
87 return tracks
88
89 def _paths_for(
90 self, subtitles: List[Dict[str, Any]], title: str, folder_path: str
91 ) -> List[tuple]:
92 """(path, track) per track, with names that cannot collide or escape.
93
94 The label comes from wyzie's own `display`, because Kodi labels a
95 subtitle by its filename and `display` already distinguishes
96 'Portuguese (BR)' from 'Portuguese' -- a distinction the language codes
97 (pb/pt) only carry if we happen to know that mapping. It arrives inside
98 a remote JSON body, so it goes through slugify_title like the title.
99 """
100 stem = slugify_title(title) or "Subtitle"
101 seen: Dict[str, int] = {}
102 out = []
103 for track in subtitles:
104 label = slugify_title(str(track.get("display") or track.get("language") or "und"))
105 label = os.path.basename(label).strip(". ") or "und"
106 n = seen[label] = seen.get(label, 0) + 1
107 suffix = label if n == 1 else "{0}.{1}".format(label, n)
108 out.append((os.path.join(folder_path, "{0}.{1}.srt".format(stem, suffix)), track))
109 return out
110
111 def download_subtitles_batch(
112 self,
113 subtitles: List[Dict[str, Any]],
114 title: str,
115 season: Optional[int] = None,
116 episode: Optional[int] = None,
117 folder_path: Optional[str] = None,
118 ) -> List[str]:
119 """Fetch `subtitles` into `folder_path`. Returns the paths that exist.
120
121 One track failing costs that track and nothing else: a player with 31
122 subtitles is not meaningfully worse than one with 32, and is very much
123 better than one with none because the 32nd timed out.
124 """
125 if not subtitles or not folder_path:
126 return []
127 os.makedirs(folder_path, exist_ok=True)
128
129 planned = self._paths_for(subtitles, title, folder_path)
130 have = [p for p, _ in planned
131 if os.path.exists(p) and os.path.getsize(p) > 0]
132 todo = [(p, t) for p, t in planned if p not in set(have)]
133
134 def _one(item):
135 path, track = item
136 try:
137 response = requests.get(track["url"], timeout=DOWNLOAD_TIMEOUT,
138 headers=WYZIE_HEADERS)
139 if response.status_code != 200:
140 kodilog("Wyzie: {0} -> HTTP {1}".format(
141 os.path.basename(path), response.status_code))
142 return None
143 body = response.content
144 except Exception as e:
145 kodilog("Wyzie: {0} failed ({1})".format(os.path.basename(path), e))
146 return None
147 if not body:
148 # An empty file is worse than none: the player offers a track
149 # that shows nothing, and the on-disk check above would keep it.
150 kodilog("Wyzie: {0} came back empty".format(os.path.basename(path)))
151 return None
152 try:
153 with open(path, "wb") as f:
154 f.write(body)
155 except OSError as e:
156 kodilog("Wyzie: could not write {0} ({1})".format(
157 os.path.basename(path), e))
158 return None
159 return path
160
161 paths = list(have)
162 if todo:
163 with ThreadPoolExecutor(max_workers=DOWNLOAD_WORKERS) as pool:
164 paths += [p for p in pool.map(_one, todo) if p]
165 return sorted(paths)
plugin.video.jacktook/lib/player.py +1 -1
……@@ -328,7 +328,7 @@ class JacktookPLayer(xbmc.Player):
328328 list_item.setSubtitles(subtitles_path)
329329 self.setSubtitleStream(0)
330330 self.subtitles_found = True
331 elif get_setting("stremio_subtitle_enabled"):
331 elif get_setting("stremio_subtitle_enabled") or get_setting("wyzie_subtitle_enabled"):
332332 try:
333333 from lib.clients.subtitle.submanager import SubtitleManager
334334
plugin.video.jacktook/resources/language/English/strings.po +8 -0
……@@ -4522,3 +4522,11 @@ msgstr ""
45224522msgctxt "#90307"
45234523msgid "Open the settings for the selected external scraper module."
45244524msgstr ""
4525
4526msgctxt "#90836"
4527msgid "Wyzie Subtitles (recommended)"
4528msgstr ""
4529
4530msgctxt "#90837"
4531msgid "The source the stream's own player uses. Needs no account or API key, and works from either a TMDB or an IMDb id."
4532msgstr ""
plugin.video.jacktook/resources/settings.xml +6 -0
……@@ -1402,6 +1402,12 @@
14021402 <control type="toggle" />
14031403 </setting>
14041404 </group>
1405 <group id="wyzie_subtitle" label="90836">
1406 <setting id="wyzie_subtitle_enabled" type="boolean" label="30734" help="90837">
1407 <default>true</default>
1408 <control type="toggle" />
1409 </setting>
1410 </group>
14051411 <group id="stremio_subtitle" label="30893">
14061412 <setting id="stremio_subtitle_enabled" type="boolean" label="30734" help="">
14071413 <default>false</default>
plugin.video.jacktook/tests/unit/test_wyzie_subtitles.py +338 -0
……@@ -0,0 +1,338 @@
1"""Subtitles from the source the player itself uses.
2
3The streams we play carry no subtitles. Measured 2026-09-04 on No Other Choice
4(tmdb 639988): the HLS master lists four muxed A/V variants and no
5#EXT-X-MEDIA at all. The vidcore player gets them from a separate endpoint --
6`vidcore.net/wyzie?id=<tmdb|imdb>[&season&episode]`, found in the 2026-07-21
7deobfuscation -- which returns `[{display, language, url, encoding}]`, needs no
8key and no browser, and answers for imdb ids as readily as tmdb ones (verified:
9id=1396 and id=tt0903747 return byte-identical bodies). That makes it usable for
10every source Jacktook plays, not only vidcore.
11
12Run: python3 -m pytest plugin.video.jacktook/tests/unit/test_wyzie_subtitles.py -q
13"""
14
15import os
16from unittest.mock import MagicMock, patch
17
18import pytest
19
20from lib.clients.subtitle.wyzie import WyzieClient
21
22
23@pytest.fixture
24def client():
25 return WyzieClient(MagicMock())
26
27
28def _resp(payload, status=200):
29 r = MagicMock()
30 r.status_code = status
31 r.json.return_value = payload
32 return r
33
34
35TRACKS = [
36 {"display": "English", "language": "en", "url": "http://x/en", "encoding": "UTF-8"},
37 {"display": "French", "language": "fr", "url": "http://x/fr", "encoding": "UTF-8"},
38]
39
40
41# ------------------------------------------------------------ asking for them
42
43class TestQuery:
44 def test_a_movie_is_asked_for_by_id_alone(self, client):
45 with patch("lib.clients.subtitle.wyzie.requests.get",
46 return_value=_resp(TRACKS)) as g:
47 client.get_subtitles("movies", "639988")
48 url = g.call_args[0][0]
49 assert "id=639988" in url
50 assert "season" not in url and "episode" not in url
51
52 def test_an_episode_carries_season_and_episode(self, client):
53 with patch("lib.clients.subtitle.wyzie.requests.get",
54 return_value=_resp(TRACKS)) as g:
55 client.get_subtitles("tv", "138502", season=1, episode=1)
56 url = g.call_args[0][0]
57 assert "id=138502" in url and "season=1" in url and "episode=1" in url
58
59 def test_an_imdb_id_is_passed_through_unchanged(self, client):
60 """Verified against the live endpoint: tt0903747 and 1396 return the
61 same body, so no id translation is needed or wanted."""
62 with patch("lib.clients.subtitle.wyzie.requests.get",
63 return_value=_resp(TRACKS)) as g:
64 client.get_subtitles("tv", "tt0903747", season=1, episode=1)
65 assert "id=tt0903747" in g.call_args[0][0]
66
67 def test_a_referer_is_sent(self, client):
68 """The vidcore CDN 403s a request without one, and the /wyzie proxy is
69 on the same host."""
70 with patch("lib.clients.subtitle.wyzie.requests.get",
71 return_value=_resp(TRACKS)) as g:
72 client.get_subtitles("movies", "639988")
73 headers = g.call_args.kwargs.get("headers") or {}
74 assert "vidcore" in headers.get("Referer", "")
75
76
77class TestNoResults:
78 def test_a_404_is_no_subtitles_not_an_error(self, client):
79 """Verified: X-Men '97 S2E8 answers 404 with a zero-length body while
80 S1E1 returns 34 tracks. A title with none is ordinary."""
81 with patch("lib.clients.subtitle.wyzie.requests.get",
82 return_value=_resp(None, status=404)):
83 assert client.get_subtitles("tv", "138502", season=2, episode=8) == []
84
85 def test_a_network_failure_is_no_subtitles(self, client):
86 with patch("lib.clients.subtitle.wyzie.requests.get",
87 side_effect=OSError("no route to host")):
88 assert client.get_subtitles("movies", "639988") == []
89
90 def test_a_non_list_body_is_no_subtitles(self, client):
91 with patch("lib.clients.subtitle.wyzie.requests.get",
92 return_value=_resp({"error": "nope"})):
93 assert client.get_subtitles("movies", "639988") == []
94
95 def test_entries_without_a_url_are_dropped(self, client):
96 body = [{"display": "English", "language": "en", "url": "http://x/en"},
97 {"display": "Broken", "language": "xx"}]
98 with patch("lib.clients.subtitle.wyzie.requests.get",
99 return_value=_resp(body)):
100 got = client.get_subtitles("movies", "639988")
101 assert [t["language"] for t in got] == ["en"]
102
103
104# ---------------------------------------------------------------- downloading
105
106@pytest.fixture
107def downloads(monkeypatch):
108 calls = []
109
110 def fake(url, timeout=None, headers=None):
111 calls.append(url)
112 r = MagicMock()
113 r.status_code = 200
114 r.content = b"1\n00:00:01,000 --> 00:00:02,000\nhello\n"
115 return r
116
117 monkeypatch.setattr("lib.clients.subtitle.wyzie.requests.get", fake)
118 return calls
119
120
121class TestDownload:
122 def test_files_land_in_the_folder_with_readable_names(self, client, downloads, tmp_path):
123 paths = client.download_subtitles_batch(
124 TRACKS, title="No Other Choice", folder_path=str(tmp_path))
125 names = sorted(os.path.basename(p) for p in paths)
126 assert names == ["No Other Choice.English.srt", "No Other Choice.French.srt"]
127 assert all(os.path.getsize(p) > 0 for p in paths)
128
129 def test_the_display_name_is_used_because_kodi_labels_by_filename(
130 self, client, downloads, tmp_path):
131 """wyzie's own label is better than anything we could map a code to:
132 it distinguishes 'Portuguese (BR)' from 'Portuguese', which pb/pt would
133 only do if we happened to know that mapping.
134
135 The brackets do not survive slugify_title -- the repo's existing
136 sanitiser, used here rather than a second one that keeps them, because
137 the distinction is what matters and 'Portuguese BR' carries it."""
138 tracks = [{"display": "Portuguese (BR)", "language": "pb", "url": "http://x/pb"},
139 {"display": "Portuguese", "language": "pt", "url": "http://x/pt"}]
140 paths = client.download_subtitles_batch(
141 tracks, title="Film", folder_path=str(tmp_path))
142 names = sorted(os.path.basename(p) for p in paths)
143 assert names == ["Film.Portuguese BR.srt", "Film.Portuguese.srt"]
144 assert len(set(names)) == 2
145
146 def test_two_tracks_with_the_same_label_do_not_collide(
147 self, client, downloads, tmp_path):
148 tracks = [dict(TRACKS[0]), dict(TRACKS[0], url="http://x/en2")]
149 paths = client.download_subtitles_batch(
150 tracks, title="Film", folder_path=str(tmp_path))
151 assert len(set(paths)) == 2
152 assert all(os.path.exists(p) for p in paths)
153
154 def test_a_label_cannot_escape_the_folder(self, client, downloads, tmp_path):
155 """`display` arrives inside a remote JSON body and reaches a filename."""
156 tracks = [{"display": "../../../etc/passwd", "language": "en",
157 "url": "http://x/en"}]
158 paths = client.download_subtitles_batch(
159 tracks, title="Film", folder_path=str(tmp_path))
160 for p in paths:
161 assert os.path.dirname(os.path.realpath(p)) == os.path.realpath(str(tmp_path))
162
163 def test_one_failure_does_not_lose_the_rest(self, client, monkeypatch, tmp_path):
164 def flaky(url, timeout=None, headers=None):
165 r = MagicMock()
166 if url.endswith("/fr"):
167 raise OSError("connection reset")
168 r.status_code = 200
169 r.content = b"1\n00:00:01,000 --> 00:00:02,000\nhi\n"
170 return r
171 monkeypatch.setattr("lib.clients.subtitle.wyzie.requests.get", flaky)
172 paths = client.download_subtitles_batch(
173 TRACKS, title="Film", folder_path=str(tmp_path))
174 assert [os.path.basename(p) for p in paths] == ["Film.English.srt"]
175
176 def test_an_empty_body_is_not_written(self, client, monkeypatch, tmp_path):
177 def empty(url, timeout=None, headers=None):
178 r = MagicMock(); r.status_code = 200; r.content = b""
179 return r
180 monkeypatch.setattr("lib.clients.subtitle.wyzie.requests.get", empty)
181 assert client.download_subtitles_batch(
182 TRACKS, title="Film", folder_path=str(tmp_path)) == []
183
184 def test_a_file_already_on_disk_is_not_refetched(self, client, downloads, tmp_path):
185 client.download_subtitles_batch(TRACKS, title="Film", folder_path=str(tmp_path))
186 assert len(downloads) == 2
187 client.download_subtitles_batch(TRACKS, title="Film", folder_path=str(tmp_path))
188 assert len(downloads) == 2, "refetched subtitles already on disk"
189
190 def test_the_folder_is_created_if_missing(self, client, downloads, tmp_path):
191 target = str(tmp_path / "deep" / "nested")
192 paths = client.download_subtitles_batch(
193 TRACKS, title="Film", folder_path=target)
194 assert len(paths) == 2 and all(os.path.exists(p) for p in paths)
195
196 def test_downloads_run_in_parallel(self, client, monkeypatch, tmp_path):
197 """32 tracks is the normal case, and this happens while a player waits.
198 Measured against the live endpoint: 2.9s at 8 workers, where the slowest
199 single request was 0.95s -- i.e. sequential would be ~20s, past the
200 point Kodi gives up on opening a stream."""
201 import threading, time
202 peak = {"n": 0, "cur": 0}
203 lock = threading.Lock()
204
205 def slow(url, timeout=None, headers=None):
206 with lock:
207 peak["cur"] += 1
208 peak["n"] = max(peak["n"], peak["cur"])
209 time.sleep(0.15)
210 with lock:
211 peak["cur"] -= 1
212 r = MagicMock(); r.status_code = 200; r.content = b"x"
213 return r
214
215 monkeypatch.setattr("lib.clients.subtitle.wyzie.requests.get", slow)
216 many = [{"display": f"Lang{i}", "language": f"l{i}", "url": f"http://x/{i}"}
217 for i in range(16)]
218 client.download_subtitles_batch(many, title="Film", folder_path=str(tmp_path))
219 assert peak["n"] > 1, "downloads ran one at a time"
220
221
222# ------------------------------------------------- wired into SubtitleManager
223
224from lib.clients.subtitle.submanager import SubtitleManager # noqa: E402
225
226
227def _manager(data=None):
228 data = data or {"title": "No Other Choice", "mode": "movies",
229 "ids": {"imdb_id": "tt31967670", "tmdb_id": "639988"}}
230 return SubtitleManager(data, MagicMock())
231
232
233def _settings(**overrides):
234 """get_setting stub. Wyzie on, everything else off, unless told otherwise."""
235 values = {"wyzie_subtitle_enabled": True, "stremio_subtitle_enabled": False,
236 "deepl_enabled": False, "auto_subtitle_download": False}
237 values.update(overrides)
238 return lambda key, *a, **k: values.get(key, False)
239
240
241class TestManagerPrefersWyzie:
242 def test_wyzie_is_asked_first_and_its_answer_is_used(self, tmp_path):
243 m = _manager()
244 m.wyzie_client.get_subtitles = MagicMock(return_value=TRACKS)
245 m.wyzie_client.download_subtitles_batch = MagicMock(
246 return_value=["/subs/a.srt"])
247 m.opensub_client.get_subtitles = MagicMock(return_value=TRACKS)
248 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
249 got = m.fetch_subtitles(folder_path=str(tmp_path))
250 assert got == ["/subs/a.srt"]
251 m.opensub_client.get_subtitles.assert_not_called()
252
253 def test_it_falls_back_to_opensubtitles_when_wyzie_has_nothing(self, tmp_path):
254 """Not an either/or. Wyzie is keyless and usually richer, but a title it
255 lacks is exactly when the other source is worth having."""
256 m = _manager()
257 m.wyzie_client.get_subtitles = MagicMock(return_value=[])
258 m.opensub_client.get_subtitles = MagicMock(return_value=TRACKS)
259 m.opensub_client.download_subtitles_batch = MagicMock(
260 return_value=["/subs/os.srt"])
261 with patch("lib.clients.subtitle.submanager.get_setting",
262 _settings(stremio_subtitle_enabled=True)):
263 got = m.fetch_subtitles(folder_path=str(tmp_path))
264 assert got == ["/subs/os.srt"]
265
266 def test_opensubtitles_is_not_called_when_it_is_switched_off(self, tmp_path):
267 m = _manager()
268 m.wyzie_client.get_subtitles = MagicMock(return_value=[])
269 m.opensub_client.get_subtitles = MagicMock()
270 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
271 assert m.fetch_subtitles(folder_path=str(tmp_path)) is None
272 m.opensub_client.get_subtitles.assert_not_called()
273
274 def test_wyzie_is_not_called_when_it_is_switched_off(self, tmp_path):
275 m = _manager()
276 m.wyzie_client.get_subtitles = MagicMock()
277 m.opensub_client.get_subtitles = MagicMock(return_value=None)
278 with patch("lib.clients.subtitle.submanager.get_setting",
279 _settings(wyzie_subtitle_enabled=False,
280 stremio_subtitle_enabled=True)):
281 m.fetch_subtitles(folder_path=str(tmp_path))
282 m.wyzie_client.get_subtitles.assert_not_called()
283
284 def test_a_title_with_only_a_tmdb_id_still_gets_subtitles(self, tmp_path):
285 """The fxl:/tmdb catalogs have no imdb id at all, and the old path gave
286 up on those outright ('no_imdb'). Wyzie takes either."""
287 m = _manager({"title": "No Other Choice", "mode": "movies",
288 "ids": {"tmdb_id": "639988"}})
289 m.wyzie_client.get_subtitles = MagicMock(return_value=TRACKS)
290 m.wyzie_client.download_subtitles_batch = MagicMock(
291 return_value=["/subs/a.srt"])
292 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
293 got = m.fetch_subtitles(folder_path=str(tmp_path))
294 assert got == ["/subs/a.srt"]
295 assert m.wyzie_client.get_subtitles.call_args[0][1] == "639988"
296
297 def test_an_imdb_id_is_preferred_when_both_are_present(self, tmp_path):
298 m = _manager()
299 m.wyzie_client.get_subtitles = MagicMock(return_value=[])
300 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
301 m.fetch_subtitles(folder_path=str(tmp_path))
302 assert m.wyzie_client.get_subtitles.call_args[0][1] == "tt31967670"
303
304 def test_no_id_of_either_kind_is_still_refused(self, tmp_path):
305 m = _manager({"title": "Mystery", "mode": "movies", "ids": {}})
306 m.wyzie_client.get_subtitles = MagicMock()
307 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
308 assert m.fetch_subtitles(folder_path=str(tmp_path)) is None
309 assert m.last_fetch_status == "no_imdb"
310 m.wyzie_client.get_subtitles.assert_not_called()
311
312 def test_an_episode_passes_season_and_episode(self, tmp_path):
313 m = _manager({"title": "X-Men 97", "mode": "tv",
314 "ids": {"imdb_id": "tt13623148"},
315 "tv_data": {"season": 1, "episode": 1}})
316 m.wyzie_client.get_subtitles = MagicMock(return_value=[])
317 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
318 m.fetch_subtitles(folder_path=str(tmp_path))
319 args = m.wyzie_client.get_subtitles.call_args[0]
320 assert args[2] == 1 and args[3] == 1
321
322
323def test_the_player_runs_subtitles_when_only_wyzie_is_enabled():
324 """handle_subtitles gates the whole SubtitleManager path on
325 stremio_subtitle_enabled. Wyzie has to open that gate too, or enabling it
326 alone does nothing at all."""
327 import re
328 from pathlib import Path
329
330 # Read the file rather than the class: JacktookPLayer inherits from
331 # xbmc.Player, which the test harness stubs with a MagicMock, so the class
332 # object carries no source. test_player.py reads it this way for the same
333 # reason.
334 source = (Path(__file__).resolve().parents[2] / "lib" / "player.py").read_text()
335 body = re.search(r"def handle_subtitles\(self.*?\n def ", source, re.S)
336 assert body is not None, "handle_subtitles not found in player.py"
337 assert "wyzie_subtitle_enabled" in body.group(0), \
338 "handle_subtitles never checks the wyzie setting"