| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | <?xml version="1.0" encoding="utf-8"?> |
| 2 | <addon id="plugin.video.jacktook" name="Jacktook" version="1.17.0" provider-name="Sam-Max"> | |
| 2 | <addon id="plugin.video.jacktook" name="Jacktook" version="1.18.0" 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"/> |
| … | … | @@ -1,17 +1,23 @@ |
| 1 | """Subtitles that belong to the library copy being played. | |
| 1 | """Subtitles that belong to the library copy, or the live stream, being played. | |
| 2 | 2 | |
| 3 | 3 | vsembed serves a library copy at `/mp4/<kind>/<ident>.mp4` and measures its |
| 4 | 4 | sidecar subtitles against that copy's own audio, correcting or withdrawing |
| 5 | 5 | each one. Only Jellyfin reads the folder, so on 2026-10-18 Kodi was still |
| 6 | 6 | showing wyzie's English for No Other Choice -- the track the server had found |
| 7 | 2.25 s off and drifting -- out of a per-TMDB cache on the Pi (md5-identical to | |
| 8 | the unsynced original). The server now lists a copy's sidecars at | |
| 7 | out of sync and drifting -- out of a per-TMDB cache on the Pi (md5-identical | |
| 8 | to the unsynced original). The server lists a copy's sidecars at | |
| 9 | 9 | `/subs/<kind>/<ident>.json` on the same host, and this fetches them. |
| 10 | 10 | |
| 11 | They are cached per COPY, keyed on the list URL, and re-downloaded whenever the | |
| 12 | server's identity (size, mtime) for a track changes, so a re-sync on the | |
| 13 | server reaches the Pi on the next play. Only tracks the server lists now are | |
| 14 | offered; a withdrawn one may linger on disk but is never handed to the player. | |
| 11 | A LIVE stream (vidcore, HdHub, or /mp4/ before a copy exists) has no copy to | |
| 12 | measure. Asked with `?src=`, the server samples the stream's own audio and | |
| 13 | places each English track against it (~30-60 s); until then it answers | |
| 14 | "pending". The player starts on wyzie's raw tracks and swaps the synced one in | |
| 15 | when watch_live() returns it. | |
| 16 | ||
| 17 | Tracks are cached per list URL, and re-downloaded whenever the server's | |
| 18 | identity (size, mtime) for one changes, so a re-sync on the server reaches the | |
| 19 | Pi on the next play. Only tracks the server lists now are offered; a withdrawn | |
| 20 | one may linger on disk but is never handed to the player. | |
| 15 | 21 | """ |
| 16 | 22 | |
| 17 | 23 | import hashlib |
| … | … | @@ -19,17 +25,22 @@ import json |
| 19 | 25 | import os |
| 20 | 26 | import re |
| 21 | 27 | from concurrent.futures import ThreadPoolExecutor |
| 22 | from typing import Any, Dict, List, Optional | |
| 28 | from typing import Any, Callable, Dict, List, Optional, Tuple | |
| 23 | 29 | |
| 24 | 30 | import requests |
| 25 | 31 | |
| 26 | 32 | from lib.utils.kodi.utils import kodilog |
| 27 | 33 | |
| 28 | 34 | # The ident is `tt<n>` or `tmdb:<n>`, plus `:<season>:<episode>` for a series |
| 29 | # -- exactly what vsembed's /mp4/ routes accept, so nothing else matches. | |
| 35 | # -- exactly what vsembed's routes accept, so nothing else matches. | |
| 36 | _IDENT = r"((?:tt\d+|tmdb(?::|%3A)\d+)(?:(?::|%3A)\d+(?::|%3A)\d+)?)" | |
| 30 | 37 | _LIBRARY_MP4 = re.compile( |
| 31 | r"^(https?://[^/?#]+)/mp4/(movie|series)/((?:tt\d+|tmdb(?::|%3A)\d+)" | |
| 32 | r"(?:(?::|%3A)\d+(?::|%3A)\d+)?)\.mp4(?:[?#].*)?$", re.IGNORECASE) | |
| 38 | r"^(https?://[^/?#]+)/mp4/(movie|series)/" + _IDENT + r"\.mp4(?:[?#].*)?$", | |
| 39 | re.IGNORECASE) | |
| 40 | _VSEMBED_STREAM = re.compile( | |
| 41 | r"^(https?://[^/?#]+)/(mp4|vidcore|hdhub)/(movie|series)/" + _IDENT | |
| 42 | + r"\.(?:mp4|m3u8|mkv)(?:\?([^#]*))?(?:#.*)?$", re.IGNORECASE) | |
| 43 | _HEIGHT = re.compile(r"(?:^|&)h=(\d+)") | |
| 33 | 44 | |
| 34 | 45 | # The server is on the LAN; a short wait keeps a down server from delaying |
| 35 | 46 | # playback, and the caller falls back to wyzie. |
| … | … | @@ -37,6 +48,9 @@ LIST_TIMEOUT = 5 |
| 37 | 48 | DOWNLOAD_TIMEOUT = 15 |
| 38 | 49 | DOWNLOAD_WORKERS = 8 |
| 39 | 50 | IDENTITY_FILE = ".identity.json" |
| 51 | # Live: the server measured ~30 s (vidcore) to ~60 s (HdHub), plus a resolve. | |
| 52 | LIVE_INTERVAL = 10 | |
| 53 | LIVE_DEADLINE = 6 * 60 | |
| 40 | 54 | |
| 41 | 55 | |
| 42 | 56 | def library_list_url(stream_url: Optional[str]) -> Optional[str]: |
| … | … | @@ -47,6 +61,35 @@ def library_list_url(stream_url: Optional[str]) -> Optional[str]: |
| 47 | 61 | return "{0}/subs/{1}/{2}.json".format(m.group(1), m.group(2), m.group(3)) |
| 48 | 62 | |
| 49 | 63 | |
| 64 | def live_list_url(stream_url: Optional[str]) -> Optional[str]: | |
| 65 | """The live-sync list URL for any vsembed stream, else None.""" | |
| 66 | m = _VSEMBED_STREAM.match(stream_url or "") | |
| 67 | if not m: | |
| 68 | return None | |
| 69 | h = _HEIGHT.search(m.group(5) or "") | |
| 70 | return "{0}/subs/{1}/{2}.json?src={3}&h={4}".format( | |
| 71 | m.group(1), m.group(3), m.group(4), m.group(2).lower(), | |
| 72 | h.group(1) if h else "1080") | |
| 73 | ||
| 74 | ||
| 75 | def watch_live(client: "LibrarySubtitleClient", stream_url: str, | |
| 76 | still_playing: Callable[[], bool], wait: Callable[[float], bool], | |
| 77 | deadline: float = LIVE_DEADLINE, | |
| 78 | interval: float = LIVE_INTERVAL) -> Optional[List[str]]: | |
| 79 | """Ask until the server has placed the stream's subtitles. Paths, or None | |
| 80 | if it has none, playback stopped, Kodi is closing or time ran out. | |
| 81 | `wait(seconds)` returns False when Kodi is closing.""" | |
| 82 | for _ in range(int(deadline // interval) + 1): | |
| 83 | status, paths = client.poll_live(stream_url) | |
| 84 | if status == "ready": | |
| 85 | return paths | |
| 86 | if status != "pending" or not still_playing(): | |
| 87 | return None | |
| 88 | if not wait(interval): | |
| 89 | return None | |
| 90 | return None | |
| 91 | ||
| 92 | ||
| 50 | 93 | class LibrarySubtitleClient: |
| 51 | 94 | def __init__(self, root: str): |
| 52 | 95 | self.root = root |
| … | … | @@ -55,35 +98,29 @@ class LibrarySubtitleClient: |
| 55 | 98 | digest = hashlib.sha1(list_url.encode("utf-8")).hexdigest()[:16] |
| 56 | 99 | return os.path.join(self.root, digest) |
| 57 | 100 | |
| 58 | def _listing(self, list_url: str) -> Optional[List[Dict[str, Any]]]: | |
| 101 | def _listing(self, list_url: str) -> Tuple[str, Optional[List[Dict[str, Any]]]]: | |
| 102 | """(status, tracks). A held copy's answer carries no status: ready.""" | |
| 59 | 103 | try: |
| 60 | 104 | response = requests.get(list_url, timeout=LIST_TIMEOUT) |
| 61 | 105 | if response.status_code != 200: |
| 62 | 106 | kodilog("Library subtitles: {0} -> HTTP {1}".format( |
| 63 | 107 | list_url, response.status_code)) |
| 64 | return None | |
| 108 | return "none", None | |
| 65 | 109 | data = response.json() |
| 66 | 110 | except Exception as e: |
| 67 | 111 | kodilog("Library subtitles: {0} failed ({1})".format(list_url, e)) |
| 68 | return None | |
| 69 | subs = data.get("subtitles") if isinstance(data, dict) else None | |
| 112 | return "none", None | |
| 113 | if not isinstance(data, dict): | |
| 114 | return "none", None | |
| 115 | subs = data.get("subtitles") | |
| 70 | 116 | if not isinstance(subs, list): |
| 71 | return None | |
| 72 | return [s for s in subs if isinstance(s, dict) and s.get("url") and s.get("id")] | |
| 73 | ||
| 74 | def get_subtitles(self, stream_url: Optional[str]) -> Optional[List[str]]: | |
| 75 | """Paths of the copy's own subtitles, or None to fall through. | |
| 76 | ||
| 77 | None for anything that is not a library copy, a server that cannot be | |
| 78 | reached or predates the route, and a copy with no subtitles at all. | |
| 79 | """ | |
| 80 | list_url = library_list_url(stream_url) | |
| 81 | if not list_url: | |
| 82 | return None | |
| 83 | listing = self._listing(list_url) | |
| 84 | if not listing: | |
| 85 | return None | |
| 117 | return "none", None | |
| 118 | tracks = [s for s in subs if isinstance(s, dict) and s.get("url") and s.get("id")] | |
| 119 | return str(data.get("status") or "ready"), tracks | |
| 86 | 120 | |
| 121 | def _materialise(self, list_url: str, listing: List[Dict[str, Any]], | |
| 122 | suffix: str = "") -> Optional[List[str]]: | |
| 123 | """Download what is not already current; paths of the listed tracks.""" | |
| 87 | 124 | folder = self._folder(list_url) |
| 88 | 125 | os.makedirs(folder, exist_ok=True) |
| 89 | 126 | identity_path = os.path.join(folder, IDENTITY_FILE) |
| … | … | @@ -99,7 +136,7 @@ class LibrarySubtitleClient: |
| 99 | 136 | name = os.path.basename(str(track["id"])).strip(". ") |
| 100 | 137 | if not name: |
| 101 | 138 | continue |
| 102 | planned.append((os.path.join(folder, name + ".srt"), name, track)) | |
| 139 | planned.append((os.path.join(folder, name + suffix + ".srt"), name, track)) | |
| 103 | 140 | |
| 104 | 141 | def _current(item): |
| 105 | 142 | path, name, track = item |
| … | … | @@ -142,3 +179,31 @@ class LibrarySubtitleClient: |
| 142 | 179 | kodilog("Library subtitles: {0} track(s) for {1} ({2} downloaded)".format( |
| 143 | 180 | len(paths), list_url, len(fresh))) |
| 144 | 181 | return paths or None |
| 182 | ||
| 183 | def get_subtitles(self, stream_url: Optional[str]) -> Optional[List[str]]: | |
| 184 | """Paths of the copy's own subtitles, or None to fall through. | |
| 185 | ||
| 186 | None for anything that is not a library copy, a server that cannot be | |
| 187 | reached or predates the route, and a copy with no subtitles at all. | |
| 188 | """ | |
| 189 | list_url = library_list_url(stream_url) | |
| 190 | if not list_url: | |
| 191 | return None | |
| 192 | _status, listing = self._listing(list_url) | |
| 193 | if not listing: | |
| 194 | return None | |
| 195 | return self._materialise(list_url, listing) | |
| 196 | ||
| 197 | def poll_live(self, stream_url: Optional[str]) -> Tuple[str, Optional[List[str]]]: | |
| 198 | """One ask: ("pending", None), ("ready", paths) or ("none", None).""" | |
| 199 | list_url = live_list_url(stream_url) | |
| 200 | if not list_url: | |
| 201 | return "none", None | |
| 202 | status, listing = self._listing(list_url) | |
| 203 | if status == "pending": | |
| 204 | return "pending", None | |
| 205 | if status != "ready" or not listing: | |
| 206 | return "none", None | |
| 207 | # `.synced` in the name: Kodi shows it beside wyzie's raw English. | |
| 208 | paths = self._materialise(list_url, listing, suffix=".synced") | |
| 209 | return ("ready", paths) if paths else ("none", None) |
| … | … | @@ -72,6 +72,7 @@ class SubtitleManager(KodiJsonRpcClient): |
| 72 | 72 | self.notification = notification |
| 73 | 73 | self.opensub_client = OpenSubtitleStremioClient(notification) |
| 74 | 74 | self.wyzie_client = WyzieClient(notification) |
| 75 | self.from_library = False | |
| 75 | 76 | self.library_client = LibrarySubtitleClient( |
| 76 | 77 | os.path.join(ADDON_PROFILE_PATH, "Subtitles", "library")) |
| 77 | 78 | self.translator = DeepLTranslator(notification) |
| … | … | @@ -112,12 +113,14 @@ class SubtitleManager(KodiJsonRpcClient): |
| 112 | 113 | title: Optional[str] = None, |
| 113 | 114 | ) -> Optional[List[str]]: |
| 114 | 115 | self.last_fetch_status = None |
| 116 | self.from_library = False | |
| 115 | 117 | # A vsembed library copy brings its own subtitles, measured against |
| 116 | 118 | # its own audio on the server. Asked before wyzie AND before the |
| 117 | 119 | # per-title cache below: on 2026-10-18 that cache held the unsynced |
| 118 | 120 | # English for No Other Choice and was reused on every play. |
| 119 | 121 | library_paths = self.library_client.get_subtitles(self.data.get("url")) |
| 120 | 122 | if library_paths: |
| 123 | self.from_library = True | |
| 121 | 124 | return library_paths |
| 122 | 125 | |
| 123 | 126 | title = title or self.data.get("title") |
| … | … | @@ -1,3 +1,4 @@ |
| 1 | import os | |
| 1 | 2 | from datetime import date |
| 2 | 3 | from json import dumps as json_dumps |
| 3 | 4 | from json import loads |
| … | … | @@ -20,6 +21,7 @@ from lib.utils.general.utils import ( |
| 20 | 21 | ) |
| 21 | 22 | from lib.utils.kodi.utils import ( |
| 22 | 23 | ADDON_HANDLE, |
| 24 | ADDON_PROFILE_PATH, | |
| 23 | 25 | PLAYLIST, |
| 24 | 26 | action_url_run, |
| 25 | 27 | build_url, |
| … | … | @@ -69,6 +71,7 @@ class JacktookPLayer(xbmc.Player): |
| 69 | 71 | self.on_error = on_error |
| 70 | 72 | self._is_trakt_scrobble_active = False |
| 71 | 73 | self._playback_was_paused = False |
| 74 | self._subs_from_library = False | |
| 72 | 75 | |
| 73 | 76 | def run(self, data=None): |
| 74 | 77 | if data is None: |
| … | … | @@ -274,6 +277,13 @@ class JacktookPLayer(xbmc.Player): |
| 274 | 277 | self.handle_subtitle_selection() |
| 275 | 278 | self.handle_playback_start() |
| 276 | 279 | |
| 280 | # A live vsembed stream: its English, synced to this stream's own | |
| 281 | # audio, arrives ~30-60 s in and replaces wyzie's raw track. | |
| 282 | if self._wants_live_subtitles(): | |
| 283 | live_thread = Thread(target=self._watch_live_subtitles) | |
| 284 | live_thread.daemon = True | |
| 285 | live_thread.start() | |
| 286 | ||
| 277 | 287 | # Fetch IntroDB segments in background if enabled |
| 278 | 288 | if self.skip_intro_enabled and self.data.get("mode") == "tv": |
| 279 | 289 | introdb_thread = Thread(target=self.fetch_introdb_segments) |
| … | … | @@ -342,6 +352,7 @@ class JacktookPLayer(xbmc.Player): |
| 342 | 352 | list_item.setSubtitles(subs_paths) |
| 343 | 353 | self.setSubtitleStream(0) |
| 344 | 354 | self.subtitles_found = True |
| 355 | self._subs_from_library = subtitle_manager.from_library | |
| 345 | 356 | except Exception as e: |
| 346 | 357 | kodilog(f"Error loading subtitles: {e}") |
| 347 | 358 | self.subtitles_found = False |
| … | … | @@ -352,6 +363,37 @@ class JacktookPLayer(xbmc.Player): |
| 352 | 363 | else: |
| 353 | 364 | kodilog("No subtitle handling method selected, skipping subtitle loading") |
| 354 | 365 | |
| 366 | def _wants_live_subtitles(self) -> bool: | |
| 367 | """A vsembed stream with no library copy behind it, subtitles on.""" | |
| 368 | if self.data.get("autoplay") or getattr(self, "_subs_from_library", False): | |
| 369 | return False | |
| 370 | if not (get_setting("wyzie_subtitle_enabled") | |
| 371 | or get_setting("stremio_subtitle_enabled")): | |
| 372 | return False | |
| 373 | from lib.clients.subtitle.library import live_list_url | |
| 374 | ||
| 375 | return bool(live_list_url(self.data.get("url"))) | |
| 376 | ||
| 377 | def _watch_live_subtitles(self): | |
| 378 | from lib.clients.subtitle import library | |
| 379 | ||
| 380 | client = library.LibrarySubtitleClient( | |
| 381 | os.path.join(ADDON_PROFILE_PATH, "Subtitles", "library")) | |
| 382 | try: | |
| 383 | paths = library.watch_live( | |
| 384 | client, self.data.get("url"), self.isPlayingVideo, | |
| 385 | lambda seconds: not self.kodi_monitor.waitForAbort(seconds)) | |
| 386 | except Exception as e: | |
| 387 | kodilog(f"Live subtitles: {e}") | |
| 388 | return | |
| 389 | if not paths or not self.isPlayingVideo(): | |
| 390 | return | |
| 391 | # setSubtitles adds the file and makes it the active track. | |
| 392 | self.setSubtitles(paths[0]) | |
| 393 | self.showSubtitles(True) | |
| 394 | kodilog(f"Live subtitles: switched to {paths[0]}") | |
| 395 | self.notification("Subtitles synced to this stream", time=3000) | |
| 396 | ||
| 355 | 397 | def handle_subtitle_selection(self): |
| 356 | 398 | """ |
| 357 | 399 | Handles subtitle activation and selection logic after playback starts. |
| … | … | @@ -233,3 +233,130 @@ class TestManager: |
| 233 | 233 | m.wyzie_client.download_subtitles_batch = MagicMock(return_value=["/w/a.srt"]) |
| 234 | 234 | with patch("lib.clients.subtitle.submanager.get_setting", _settings()): |
| 235 | 235 | assert m.fetch_subtitles(folder_path=str(tmp_path)) == ["/w/a.srt"] |
| 236 | ||
| 237 | ||
| 238 | # ------------------------------------------- live streams (vsembed 0.33.0) | |
| 239 | # A stream with no library copy behind it: the server samples the stream's | |
| 240 | # own audio and places each English track (~30-60 s). The player starts on | |
| 241 | # wyzie's raw tracks and swaps the synced one in when it is ready. | |
| 242 | ||
| 243 | from lib.clients.subtitle.library import live_list_url, watch_live # noqa: E402 | |
| 244 | ||
| 245 | ||
| 246 | class TestLiveListUrl: | |
| 247 | def test_a_vidcore_movie(self): | |
| 248 | assert live_list_url(HOST + "/vidcore/movie/tmdb:1058424.m3u8?h=1080") == \ | |
| 249 | HOST + "/subs/movie/tmdb:1058424.json?src=vidcore&h=1080" | |
| 250 | ||
| 251 | def test_an_hdhub_episode(self): | |
| 252 | assert live_list_url(HOST + "/hdhub/series/tmdb:213375:1:1.mkv?h=720") == \ | |
| 253 | HOST + "/subs/series/tmdb:213375:1:1.json?src=hdhub&h=720" | |
| 254 | ||
| 255 | def test_a_library_row_without_a_copy_yet(self): | |
| 256 | assert live_list_url(MOVIE) == HOST + "/subs/movie/tmdb:639988.json?src=mp4&h=1080" | |
| 257 | ||
| 258 | def test_no_height_asks_for_the_default(self): | |
| 259 | assert live_list_url(HOST + "/vidcore/movie/tt27369017.m3u8") == \ | |
| 260 | HOST + "/subs/movie/tt27369017.json?src=vidcore&h=1080" | |
| 261 | ||
| 262 | @pytest.mark.parametrize("url", [ | |
| 263 | "https://cdn.example/vidcore/movie/x.m3u8", | |
| 264 | HOST + "/stream/movie/tt1.json", | |
| 265 | "magnet:?xt=urn:btih:abc", "", None, | |
| 266 | ]) | |
| 267 | def test_anything_else_is_not_ours(self, url): | |
| 268 | assert live_list_url(url) is None | |
| 269 | ||
| 270 | ||
| 271 | LIVE = HOST + "/vidcore/movie/tmdb:1058424.m3u8?h=1080" | |
| 272 | ||
| 273 | ||
| 274 | class LiveServer(FakeServer): | |
| 275 | def __init__(self, statuses, listing): | |
| 276 | FakeServer.__init__(self, listing) | |
| 277 | self.statuses = list(statuses) | |
| 278 | self.asked = 0 | |
| 279 | ||
| 280 | def __call__(self, url, timeout=None, headers=None): | |
| 281 | if url.endswith(".json") or ".json?" in url: | |
| 282 | self.asked += 1 | |
| 283 | status = self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0] | |
| 284 | body = dict(self.listing) if status == "ready" else {"subtitles": []} | |
| 285 | body["status"] = status | |
| 286 | return _resp(body) | |
| 287 | return FakeServer.__call__(self, url, timeout, headers) | |
| 288 | ||
| 289 | ||
| 290 | class TestPollLive: | |
| 291 | def test_pending(self, client): | |
| 292 | with _get(LiveServer(["pending"], _listing())): | |
| 293 | assert client.poll_live(LIVE) == ("pending", None) | |
| 294 | ||
| 295 | def test_ready_downloads_the_synced_tracks(self, client): | |
| 296 | with _get(LiveServer(["ready"], _listing(("en", [1, 1])))): | |
| 297 | status, paths = client.poll_live(LIVE) | |
| 298 | assert status == "ready" | |
| 299 | assert [os.path.basename(p) for p in paths] == ["en.synced.srt"] | |
| 300 | ||
| 301 | def test_none(self, client): | |
| 302 | with _get(LiveServer(["none"], _listing())): | |
| 303 | assert client.poll_live(LIVE) == ("none", None) | |
| 304 | ||
| 305 | def test_an_unreachable_server_is_none(self, client): | |
| 306 | with patch("lib.clients.subtitle.library.requests.get", side_effect=OSError("x")): | |
| 307 | assert client.poll_live(LIVE) == ("none", None) | |
| 308 | ||
| 309 | def test_not_a_vsembed_stream_is_none_without_asking(self, client): | |
| 310 | with patch("lib.clients.subtitle.library.requests.get") as g: | |
| 311 | assert client.poll_live("https://cdn.example/x.m3u8") == ("none", None) | |
| 312 | g.assert_not_called() | |
| 313 | ||
| 314 | ||
| 315 | class TestWatchLive: | |
| 316 | def test_waits_through_pending_then_returns_the_tracks(self, client): | |
| 317 | server = LiveServer(["pending", "pending", "ready"], _listing(("en", [1, 1]))) | |
| 318 | waits = [] | |
| 319 | with _get(server): | |
| 320 | paths = watch_live(client, LIVE, still_playing=lambda: True, | |
| 321 | wait=lambda s: waits.append(s) or True) | |
| 322 | assert [os.path.basename(p) for p in paths] == ["en.synced.srt"] | |
| 323 | assert server.asked == 3 and len(waits) == 2 | |
| 324 | ||
| 325 | def test_gives_up_when_the_server_has_no_answer(self, client): | |
| 326 | with _get(LiveServer(["pending", "none"], _listing())): | |
| 327 | assert watch_live(client, LIVE, lambda: True, lambda s: True) is None | |
| 328 | ||
| 329 | def test_stops_when_playback_stops(self, client): | |
| 330 | server = LiveServer(["pending"], _listing()) | |
| 331 | with _get(server): | |
| 332 | assert watch_live(client, LIVE, lambda: False, lambda s: True) is None | |
| 333 | assert server.asked == 1 | |
| 334 | ||
| 335 | def test_stops_when_kodi_is_closing(self, client): | |
| 336 | with _get(LiveServer(["pending"], _listing())): | |
| 337 | assert watch_live(client, LIVE, lambda: True, lambda s: False) is None | |
| 338 | ||
| 339 | def test_gives_up_at_the_deadline(self, client): | |
| 340 | server = LiveServer(["pending"], _listing()) | |
| 341 | with _get(server): | |
| 342 | assert watch_live(client, LIVE, lambda: True, lambda s: True, | |
| 343 | deadline=30, interval=10) is None | |
| 344 | assert server.asked == 4 | |
| 345 | ||
| 346 | ||
| 347 | class TestManagerSaysWhereSubtitlesCameFrom: | |
| 348 | def test_from_the_library(self, tmp_path): | |
| 349 | m = _manager(MOVIE) | |
| 350 | m.library_client.get_subtitles = MagicMock(return_value=["/lib/en.srt"]) | |
| 351 | with patch("lib.clients.subtitle.submanager.get_setting", _settings()): | |
| 352 | m.fetch_subtitles(folder_path=str(tmp_path)) | |
| 353 | assert m.from_library is True | |
| 354 | ||
| 355 | def test_not_from_the_library(self, tmp_path): | |
| 356 | m = _manager(LIVE) | |
| 357 | m.library_client.get_subtitles = MagicMock(return_value=None) | |
| 358 | m.wyzie_client.get_subtitles = MagicMock(return_value=[{"url": "x"}]) | |
| 359 | m.wyzie_client.download_subtitles_batch = MagicMock(return_value=["/w/a.srt"]) | |
| 360 | with patch("lib.clients.subtitle.submanager.get_setting", _settings()): | |
| 361 | m.fetch_subtitles(folder_path=str(tmp_path)) | |
| 362 | assert m.from_library is False |
| … | … | @@ -6,6 +6,8 @@ from pathlib import Path |
| 6 | 6 | from types import SimpleNamespace |
| 7 | 7 | from unittest.mock import MagicMock, call |
| 8 | 8 | |
| 9 | import pytest | |
| 10 | ||
| 9 | 11 | PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py" |
| 10 | 12 | |
| 11 | 13 | |
| … | … | @@ -693,3 +695,76 @@ def test_handle_next_dialog_action_fallback_uses_season_boundary_episode(monkeyp |
| 693 | 695 | item_data, next_tv_data = args |
| 694 | 696 | assert item_data["mode"] == "tv" |
| 695 | 697 | assert next_tv_data == {"name": "Season Two Premiere", "episode": 1, "season": 2} |
| 698 | ||
| 699 | ||
| 700 | # ---------------------------------------- live-stream subtitles (1.18.0) | |
| 701 | # vsembed syncs a live stream's English to the stream's own audio in ~30-60 s. | |
| 702 | # The player starts on wyzie's raw tracks and swaps the synced one in. | |
| 703 | ||
| 704 | LIVE_URL = "http://stortron.local:7220/vidcore/movie/tmdb:1058424.m3u8?h=1080" | |
| 705 | ||
| 706 | ||
| 707 | def _live_player(monkeypatch, url=LIVE_URL, settings=None, **attrs): | |
| 708 | player_module = _player_module(monkeypatch) | |
| 709 | values = {"wyzie_subtitle_enabled": True} | |
| 710 | values.update(settings or {}) | |
| 711 | monkeypatch.setattr(player_module, "get_setting", | |
| 712 | lambda key, *a, **k: values.get(key, False)) | |
| 713 | player = player_module.JacktookPLayer.__new__(player_module.JacktookPLayer) | |
| 714 | player.data = {"mode": "movies", "url": url} | |
| 715 | player.notification = MagicMock() | |
| 716 | player.kodi_monitor = MagicMock() | |
| 717 | player.kodi_monitor.waitForAbort.return_value = False | |
| 718 | player.isPlayingVideo = MagicMock(return_value=True) | |
| 719 | player.setSubtitles = MagicMock() | |
| 720 | player.showSubtitles = MagicMock() | |
| 721 | for k, v in attrs.items(): | |
| 722 | setattr(player, k, v) | |
| 723 | return player_module, player | |
| 724 | ||
| 725 | ||
| 726 | def test_a_live_vsembed_stream_wants_synced_subtitles(monkeypatch): | |
| 727 | _, player = _live_player(monkeypatch) | |
| 728 | assert player._wants_live_subtitles() is True | |
| 729 | ||
| 730 | ||
| 731 | @pytest.mark.parametrize("url,settings,attrs", [ | |
| 732 | ("https://cdn.example/x.m3u8", None, {}), | |
| 733 | (LIVE_URL, {"wyzie_subtitle_enabled": False}, {}), | |
| 734 | (LIVE_URL, None, {"_subs_from_library": True}), | |
| 735 | ]) | |
| 736 | def test_other_cases_do_not(monkeypatch, url, settings, attrs): | |
| 737 | _, player = _live_player(monkeypatch, url, settings, **attrs) | |
| 738 | assert player._wants_live_subtitles() is False | |
| 739 | ||
| 740 | ||
| 741 | def test_autoplay_does_not(monkeypatch): | |
| 742 | _, player = _live_player(monkeypatch) | |
| 743 | player.data["autoplay"] = True | |
| 744 | assert player._wants_live_subtitles() is False | |
| 745 | ||
| 746 | ||
| 747 | def test_the_synced_track_is_swapped_in(monkeypatch): | |
| 748 | player_module, player = _live_player(monkeypatch) | |
| 749 | import lib.clients.subtitle.library as library | |
| 750 | monkeypatch.setattr(library, "watch_live", lambda *a, **k: ["/s/en.synced.srt"]) | |
| 751 | player._watch_live_subtitles() | |
| 752 | player.setSubtitles.assert_called_once_with("/s/en.synced.srt") | |
| 753 | player.showSubtitles.assert_called_with(True) | |
| 754 | ||
| 755 | ||
| 756 | def test_nothing_is_swapped_after_playback_stopped(monkeypatch): | |
| 757 | player_module, player = _live_player(monkeypatch) | |
| 758 | import lib.clients.subtitle.library as library | |
| 759 | monkeypatch.setattr(library, "watch_live", lambda *a, **k: ["/s/en.synced.srt"]) | |
| 760 | player.isPlayingVideo.return_value = False | |
| 761 | player._watch_live_subtitles() | |
| 762 | player.setSubtitles.assert_not_called() | |
| 763 | ||
| 764 | ||
| 765 | def test_no_answer_changes_nothing(monkeypatch): | |
| 766 | player_module, player = _live_player(monkeypatch) | |
| 767 | import lib.clients.subtitle.library as library | |
| 768 | monkeypatch.setattr(library, "watch_live", lambda *a, **k: None) | |
| 769 | player._watch_live_subtitles() | |
| 770 | player.setSubtitles.assert_not_called() |