OverviewFilesCommitsIssuesDocsReleasesPeers

kodi-jacktook commits / 563da33

Swap in subtitles synced to a live stream (1.18.0)
For a vsembed stream with no library copy (vidcore, HdHub, or /mp4/ before the copy lands), vsembed 0.33.0 samples the stream's own audio and places each English track in ~30-60 s. The player starts on wyzie's raw tracks as before; once video plays, a background thread polls /subs/<kind>/<ident>.json?src=..&h=.. and, when ready, setSubtitles() the synced track (named *.synced.srt). Gives up on "none", when playback stops, when Kodi closes, or after 6 minutes. Not started for library copies, which already play their own synced tracks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
563da33ee2f17bfc8933eef40256e5849efb4244
by plankatron <fleeky@gmail.com> · 2026-10-19T01:10:55.000Z

Changed files (6) +344 -32

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.17.0" provider-name="Sam-Max">
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.18.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/library.py +96 -31
……@@ -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.
22
33vsembed serves a library copy at `/mp4/<kind>/<ident>.mp4` and measures its
44sidecar subtitles against that copy's own audio, correcting or withdrawing
55each one. Only Jellyfin reads the folder, so on 2026-10-18 Kodi was still
66showing wyzie's English for No Other Choice -- the track the server had found
72.25 s off and drifting -- out of a per-TMDB cache on the Pi (md5-identical to
8the unsynced original). The server now lists a copy's sidecars at
7out of sync and drifting -- out of a per-TMDB cache on the Pi (md5-identical
8to the unsynced original). The server lists a copy's sidecars at
99`/subs/<kind>/<ident>.json` on the same host, and this fetches them.
1010
11They are cached per COPY, keyed on the list URL, and re-downloaded whenever the
12server's identity (size, mtime) for a track changes, so a re-sync on the
13server reaches the Pi on the next play. Only tracks the server lists now are
14offered; a withdrawn one may linger on disk but is never handed to the player.
11A LIVE stream (vidcore, HdHub, or /mp4/ before a copy exists) has no copy to
12measure. Asked with `?src=`, the server samples the stream's own audio and
13places 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
15when watch_live() returns it.
16
17Tracks are cached per list URL, and re-downloaded whenever the server's
18identity (size, mtime) for one changes, so a re-sync on the server reaches the
19Pi on the next play. Only tracks the server lists now are offered; a withdrawn
20one may linger on disk but is never handed to the player.
1521"""
1622
1723import hashlib
……@@ -19,17 +25,22 @@ import json
1925import os
2026import re
2127from concurrent.futures import ThreadPoolExecutor
22from typing import Any, Dict, List, Optional
28from typing import Any, Callable, Dict, List, Optional, Tuple
2329
2430import requests
2531
2632from lib.utils.kodi.utils import kodilog
2733
2834# 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+)?)"
3037_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+)")
3344
3445# The server is on the LAN; a short wait keeps a down server from delaying
3546# playback, and the caller falls back to wyzie.
……@@ -37,6 +48,9 @@ LIST_TIMEOUT = 5
3748DOWNLOAD_TIMEOUT = 15
3849DOWNLOAD_WORKERS = 8
3950IDENTITY_FILE = ".identity.json"
51# Live: the server measured ~30 s (vidcore) to ~60 s (HdHub), plus a resolve.
52LIVE_INTERVAL = 10
53LIVE_DEADLINE = 6 * 60
4054
4155
4256def library_list_url(stream_url: Optional[str]) -> Optional[str]:
……@@ -47,6 +61,35 @@ def library_list_url(stream_url: Optional[str]) -> Optional[str]:
4761 return "{0}/subs/{1}/{2}.json".format(m.group(1), m.group(2), m.group(3))
4862
4963
64def 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
75def 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
5093class LibrarySubtitleClient:
5194 def __init__(self, root: str):
5295 self.root = root
……@@ -55,35 +98,29 @@ class LibrarySubtitleClient:
5598 digest = hashlib.sha1(list_url.encode("utf-8")).hexdigest()[:16]
5699 return os.path.join(self.root, digest)
57100
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."""
59103 try:
60104 response = requests.get(list_url, timeout=LIST_TIMEOUT)
61105 if response.status_code != 200:
62106 kodilog("Library subtitles: {0} -> HTTP {1}".format(
63107 list_url, response.status_code))
64 return None
108 return "none", None
65109 data = response.json()
66110 except Exception as e:
67111 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")
70116 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
86120
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."""
87124 folder = self._folder(list_url)
88125 os.makedirs(folder, exist_ok=True)
89126 identity_path = os.path.join(folder, IDENTITY_FILE)
……@@ -99,7 +136,7 @@ class LibrarySubtitleClient:
99136 name = os.path.basename(str(track["id"])).strip(". ")
100137 if not name:
101138 continue
102 planned.append((os.path.join(folder, name + ".srt"), name, track))
139 planned.append((os.path.join(folder, name + suffix + ".srt"), name, track))
103140
104141 def _current(item):
105142 path, name, track = item
……@@ -142,3 +179,31 @@ class LibrarySubtitleClient:
142179 kodilog("Library subtitles: {0} track(s) for {1} ({2} downloaded)".format(
143180 len(paths), list_url, len(fresh)))
144181 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)
plugin.video.jacktook/lib/clients/subtitle/submanager.py +3 -0
……@@ -72,6 +72,7 @@ class SubtitleManager(KodiJsonRpcClient):
7272 self.notification = notification
7373 self.opensub_client = OpenSubtitleStremioClient(notification)
7474 self.wyzie_client = WyzieClient(notification)
75 self.from_library = False
7576 self.library_client = LibrarySubtitleClient(
7677 os.path.join(ADDON_PROFILE_PATH, "Subtitles", "library"))
7778 self.translator = DeepLTranslator(notification)
……@@ -112,12 +113,14 @@ class SubtitleManager(KodiJsonRpcClient):
112113 title: Optional[str] = None,
113114 ) -> Optional[List[str]]:
114115 self.last_fetch_status = None
116 self.from_library = False
115117 # A vsembed library copy brings its own subtitles, measured against
116118 # its own audio on the server. Asked before wyzie AND before the
117119 # per-title cache below: on 2026-10-18 that cache held the unsynced
118120 # English for No Other Choice and was reused on every play.
119121 library_paths = self.library_client.get_subtitles(self.data.get("url"))
120122 if library_paths:
123 self.from_library = True
121124 return library_paths
122125
123126 title = title or self.data.get("title")
plugin.video.jacktook/lib/player.py +42 -0
……@@ -1,3 +1,4 @@
1import os
12from datetime import date
23from json import dumps as json_dumps
34from json import loads
……@@ -20,6 +21,7 @@ from lib.utils.general.utils import (
2021)
2122from lib.utils.kodi.utils import (
2223 ADDON_HANDLE,
24 ADDON_PROFILE_PATH,
2325 PLAYLIST,
2426 action_url_run,
2527 build_url,
……@@ -69,6 +71,7 @@ class JacktookPLayer(xbmc.Player):
6971 self.on_error = on_error
7072 self._is_trakt_scrobble_active = False
7173 self._playback_was_paused = False
74 self._subs_from_library = False
7275
7376 def run(self, data=None):
7477 if data is None:
……@@ -274,6 +277,13 @@ class JacktookPLayer(xbmc.Player):
274277 self.handle_subtitle_selection()
275278 self.handle_playback_start()
276279
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
277287 # Fetch IntroDB segments in background if enabled
278288 if self.skip_intro_enabled and self.data.get("mode") == "tv":
279289 introdb_thread = Thread(target=self.fetch_introdb_segments)
……@@ -342,6 +352,7 @@ class JacktookPLayer(xbmc.Player):
342352 list_item.setSubtitles(subs_paths)
343353 self.setSubtitleStream(0)
344354 self.subtitles_found = True
355 self._subs_from_library = subtitle_manager.from_library
345356 except Exception as e:
346357 kodilog(f"Error loading subtitles: {e}")
347358 self.subtitles_found = False
……@@ -352,6 +363,37 @@ class JacktookPLayer(xbmc.Player):
352363 else:
353364 kodilog("No subtitle handling method selected, skipping subtitle loading")
354365
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
355397 def handle_subtitle_selection(self):
356398 """
357399 Handles subtitle activation and selection logic after playback starts.
plugin.video.jacktook/tests/unit/test_library_subtitles.py +127 -0
……@@ -233,3 +233,130 @@ class TestManager:
233233 m.wyzie_client.download_subtitles_batch = MagicMock(return_value=["/w/a.srt"])
234234 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
235235 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
243from lib.clients.subtitle.library import live_list_url, watch_live # noqa: E402
244
245
246class 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
271LIVE = HOST + "/vidcore/movie/tmdb:1058424.m3u8?h=1080"
272
273
274class 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
290class 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
315class 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
347class 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
plugin.video.jacktook/tests/unit/test_player.py +75 -0
……@@ -6,6 +6,8 @@ from pathlib import Path
66from types import SimpleNamespace
77from unittest.mock import MagicMock, call
88
9import pytest
10
911PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py"
1012
1113
……@@ -693,3 +695,76 @@ def test_handle_next_dialog_action_fallback_uses_season_boundary_episode(monkeyp
693695 item_data, next_tv_data = args
694696 assert item_data["mode"] == "tv"
695697 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
704LIVE_URL = "http://stortron.local:7220/vidcore/movie/tmdb:1058424.m3u8?h=1080"
705
706
707def _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
726def 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])
736def 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
741def 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
747def 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
756def 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
765def 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()