OverviewFilesCommitsIssuesDocsReleasesPeers

kodi-jacktook commits / b76f74e

Play a library copy with its own synced subtitles (1.17.0)
No Other Choice's English drifted in Kodi although vsembed had synced it to the copy's audio on 10-04. The synced file sits beside the mp4 where only Jellyfin looks; Jacktook fetched wyzie into a per-TMDB cache and reused it. md5 proved it: the Pi's English.srt was the unsynced original. When the stream is vsembed's /mp4/<kind>/<ident>.mp4, subtitles now come from /subs/<kind>/<ident>.json on the same host (vsembed 0.30.0), cached per copy and re-downloaded when the server's identity for a track changes. Asked before wyzie and before the per-title cache; anything else (a live stream, the server down or older, a copy with none) falls through as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b76f74ee8e7537dbfdb94a4680d218e7263c625d
by plankatron <fleeky@gmail.com> · 2026-10-19T00:25:31.000Z

Changed files (4) +391 -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.16.4" provider-name="Sam-Max">
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.17.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 +144 -0
……@@ -0,0 +1,144 @@
1"""Subtitles that belong to the library copy being played.
2
3vsembed serves a library copy at `/mp4/<kind>/<ident>.mp4` and measures its
4sidecar subtitles against that copy's own audio, correcting or withdrawing
5each one. Only Jellyfin reads the folder, so on 2026-10-18 Kodi was still
6showing 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
9`/subs/<kind>/<ident>.json` on the same host, and this fetches them.
10
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.
15"""
16
17import hashlib
18import json
19import os
20import re
21from concurrent.futures import ThreadPoolExecutor
22from typing import Any, Dict, List, Optional
23
24import requests
25
26from lib.utils.kodi.utils import kodilog
27
28# 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.
30_LIBRARY_MP4 = re.compile(
31 r"^(https?://[^/?#]+)/mp4/(movie|series)/((?:tt\d+|tmdb(?::|%3A)\d+)"
32 r"(?:(?::|%3A)\d+(?::|%3A)\d+)?)\.mp4(?:[?#].*)?$", re.IGNORECASE)
33
34# The server is on the LAN; a short wait keeps a down server from delaying
35# playback, and the caller falls back to wyzie.
36LIST_TIMEOUT = 5
37DOWNLOAD_TIMEOUT = 15
38DOWNLOAD_WORKERS = 8
39IDENTITY_FILE = ".identity.json"
40
41
42def library_list_url(stream_url: Optional[str]) -> Optional[str]:
43 """`/subs/<kind>/<ident>.json` for a vsembed library copy, else None."""
44 m = _LIBRARY_MP4.match(stream_url or "")
45 if not m:
46 return None
47 return "{0}/subs/{1}/{2}.json".format(m.group(1), m.group(2), m.group(3))
48
49
50class LibrarySubtitleClient:
51 def __init__(self, root: str):
52 self.root = root
53
54 def _folder(self, list_url: str) -> str:
55 digest = hashlib.sha1(list_url.encode("utf-8")).hexdigest()[:16]
56 return os.path.join(self.root, digest)
57
58 def _listing(self, list_url: str) -> Optional[List[Dict[str, Any]]]:
59 try:
60 response = requests.get(list_url, timeout=LIST_TIMEOUT)
61 if response.status_code != 200:
62 kodilog("Library subtitles: {0} -> HTTP {1}".format(
63 list_url, response.status_code))
64 return None
65 data = response.json()
66 except Exception as e:
67 kodilog("Library subtitles: {0} failed ({1})".format(list_url, e))
68 return None
69 subs = data.get("subtitles") if isinstance(data, dict) else None
70 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
86
87 folder = self._folder(list_url)
88 os.makedirs(folder, exist_ok=True)
89 identity_path = os.path.join(folder, IDENTITY_FILE)
90 try:
91 with open(identity_path) as f:
92 held = json.load(f)
93 except Exception:
94 held = {}
95
96 planned = []
97 for track in listing:
98 # The id names a file, and it arrived over the network.
99 name = os.path.basename(str(track["id"])).strip(". ")
100 if not name:
101 continue
102 planned.append((os.path.join(folder, name + ".srt"), name, track))
103
104 def _current(item):
105 path, name, track = item
106 return (os.path.exists(path) and os.path.getsize(path) > 0
107 and held.get(name) == track.get("identity"))
108
109 def _one(item):
110 path, name, track = item
111 try:
112 response = requests.get(track["url"], timeout=DOWNLOAD_TIMEOUT)
113 if response.status_code != 200 or not response.content:
114 kodilog("Library subtitles: {0} -> HTTP {1}".format(
115 name, response.status_code))
116 return None
117 tmp = path + ".part"
118 with open(tmp, "wb") as f:
119 f.write(response.content)
120 os.replace(tmp, path)
121 except Exception as e:
122 kodilog("Library subtitles: {0} failed ({1})".format(name, e))
123 return None
124 return item
125
126 todo = [item for item in planned if not _current(item)]
127 fresh = []
128 if todo:
129 with ThreadPoolExecutor(max_workers=DOWNLOAD_WORKERS) as pool:
130 fresh = [item for item in pool.map(_one, todo) if item]
131 for _path, name, track in fresh:
132 held[name] = track.get("identity")
133 try:
134 with open(identity_path, "w") as f:
135 json.dump(held, f)
136 except OSError as e:
137 kodilog("Library subtitles: could not record identities ({0})".format(e))
138
139 failed = {id(item) for item in todo} - {id(item) for item in fresh}
140 paths = sorted(item[0] for item in planned
141 if id(item) not in failed and os.path.exists(item[0]))
142 kodilog("Library subtitles: {0} track(s) for {1} ({2} downloaded)".format(
143 len(paths), list_url, len(fresh)))
144 return paths or None
plugin.video.jacktook/lib/clients/subtitle/submanager.py +11 -0
……@@ -6,6 +6,7 @@ import xbmc
66import xbmcgui
77
88from lib.clients.subtitle.deepl import DeepLTranslator
9from lib.clients.subtitle.library import LibrarySubtitleClient
910from lib.clients.subtitle.opensubstremio import OpenSubtitleStremioClient
1011from lib.clients.subtitle.wyzie import WyzieClient
1112from lib.utils.kodi.utils import (
……@@ -71,6 +72,8 @@ class SubtitleManager(KodiJsonRpcClient):
7172 self.notification = notification
7273 self.opensub_client = OpenSubtitleStremioClient(notification)
7374 self.wyzie_client = WyzieClient(notification)
75 self.library_client = LibrarySubtitleClient(
76 os.path.join(ADDON_PROFILE_PATH, "Subtitles", "library"))
7477 self.translator = DeepLTranslator(notification)
7578 self.last_fetch_status = None
7679
……@@ -109,6 +112,14 @@ class SubtitleManager(KodiJsonRpcClient):
109112 title: Optional[str] = None,
110113 ) -> Optional[List[str]]:
111114 self.last_fetch_status = None
115 # A vsembed library copy brings its own subtitles, measured against
116 # its own audio on the server. Asked before wyzie AND before the
117 # per-title cache below: on 2026-10-18 that cache held the unsynced
118 # English for No Other Choice and was reused on every play.
119 library_paths = self.library_client.get_subtitles(self.data.get("url"))
120 if library_paths:
121 return library_paths
122
112123 title = title or self.data.get("title")
113124 mode = self.data.get("mode")
114125 ids = self.data.get("ids") or {}
plugin.video.jacktook/tests/unit/test_library_subtitles.py +235 -0
……@@ -0,0 +1,235 @@
1"""A library copy plays with ITS OWN subtitles, not wyzie's.
2
32026-10-18: No Other Choice's English drifted in Kodi although vsembed had
4synced it to the copy's audio on 10-04. The synced file sat beside the mp4,
5where only Jellyfin looks; Jacktook fetched wyzie itself into a per-TMDB cache
6and reused it silently. Proven by md5: the Pi's English.srt (025fc304...) was
7byte-identical to the UNSYNCED original, not the corrected sidecar (37840b79...).
8
9A per-TMDB cache cannot be right anyway -- two copies of one film are two
10different cuts. So when the stream is vsembed's `/mp4/<kind>/<ident>.mp4`, the
11subtitles come from `/subs/<kind>/<ident>.json` on the same host: the copy's
12own sidecars, after the server measured them against its audio. Anything else
13-- a live stream, the server unreachable, a copy with none -- falls through to
14wyzie exactly as before.
15
16Run: python3 -m pytest plugin.video.jacktook/tests/unit/test_library_subtitles.py -q
17"""
18
19import os
20from unittest.mock import MagicMock, patch
21
22import pytest
23
24from lib.clients.subtitle.library import LibrarySubtitleClient, library_list_url
25
26
27HOST = "http://192.168.50.170:7220"
28MOVIE = HOST + "/mp4/movie/tmdb:639988.mp4?h=1080"
29EPISODE = HOST + "/mp4/series/tmdb:138502:1:1.mp4?h=1080"
30
31
32def _resp(payload=None, status=200, body=b""):
33 r = MagicMock()
34 r.status_code = status
35 r.json.return_value = payload
36 r.content = body
37 return r
38
39
40def _listing(*entries):
41 return {"subtitles": [
42 {"id": sid, "lang": sid.split(".")[0],
43 "url": "{0}/subs/movie/tmdb:639988/{1}.srt".format(HOST, sid),
44 "identity": identity}
45 for sid, identity in entries]}
46
47
48class FakeServer:
49 """Answers the list URL with `listing`, each file URL with its id."""
50
51 def __init__(self, listing):
52 self.listing = listing
53 self.files = []
54
55 def __call__(self, url, timeout=None, headers=None):
56 if url.endswith(".json"):
57 return _resp(self.listing)
58 self.files.append(url)
59 sid = url.rsplit("/", 1)[1][:-len(".srt")]
60 return _resp(body="1\n00:00:01,000 --> 00:00:02,000\n{0}\n".format(sid).encode())
61
62
63# ------------------------------------------------------------ which streams
64
65class TestListUrl:
66 def test_a_library_movie(self):
67 assert library_list_url(MOVIE) == HOST + "/subs/movie/tmdb:639988.json"
68
69 def test_a_library_episode(self):
70 assert library_list_url(EPISODE) == HOST + "/subs/series/tmdb:138502:1:1.json"
71
72 def test_an_imdb_keyed_copy(self):
73 assert library_list_url(HOST + "/mp4/movie/tt31967670.mp4") == \
74 HOST + "/subs/movie/tt31967670.json"
75
76 @pytest.mark.parametrize("url", [
77 HOST + "/vidcore/movie/tmdb:639988.m3u8",
78 HOST + "/hdhub/movie/tmdb:639988.mkv",
79 "https://cdn.example/mp4/movie/x/y.mp4",
80 "magnet:?xt=urn:btih:abc",
81 "",
82 None,
83 ])
84 def test_anything_else_is_not_a_library_copy(self, url):
85 assert library_list_url(url) is None
86
87
88# ----------------------------------------------------------------- fetching
89
90@pytest.fixture
91def client(tmp_path):
92 return LibrarySubtitleClient(str(tmp_path))
93
94
95def _get(server):
96 return patch("lib.clients.subtitle.library.requests.get", side_effect=server)
97
98
99class TestFetch:
100 def test_each_listed_track_is_written_and_returned(self, client):
101 server = FakeServer(_listing(("en", [99078, 1]), ("ko", [5, 1])))
102 with _get(server):
103 paths = client.get_subtitles(MOVIE)
104 assert sorted(os.path.basename(p) for p in paths) == ["en.srt", "ko.srt"]
105 for p in paths:
106 sid = os.path.basename(p)[:-len(".srt")]
107 with open(p, "rb") as f:
108 assert sid.encode() in f.read()
109
110 def test_a_second_track_in_one_language_is_kept_apart(self, client):
111 server = FakeServer(_listing(("en", [1, 1]), ("en.2", [2, 1])))
112 with _get(server):
113 paths = client.get_subtitles(MOVIE)
114 assert sorted(os.path.basename(p) for p in paths) == ["en.2.srt", "en.srt"]
115
116 def test_an_unchanged_track_is_not_downloaded_again(self, client):
117 server = FakeServer(_listing(("en", [99078, 1])))
118 with _get(server):
119 client.get_subtitles(MOVIE)
120 client.get_subtitles(MOVIE)
121 assert len(server.files) == 1
122
123 def test_a_track_the_server_resynced_is_downloaded_again(self, client):
124 """The whole point: the server corrects a file in place, and the copy
125 on the Pi must follow it rather than outlive it."""
126 server = FakeServer(_listing(("en", [99078, 1])))
127 with _get(server):
128 client.get_subtitles(MOVIE)
129 server.listing = _listing(("en", [99100, 2]))
130 client.get_subtitles(MOVIE)
131 assert len(server.files) == 2
132
133 def test_a_track_the_server_no_longer_lists_is_not_offered(self, client):
134 server = FakeServer(_listing(("en", [1, 1]), ("de", [2, 1])))
135 with _get(server):
136 client.get_subtitles(MOVIE)
137 server.listing = _listing(("en", [1, 1]))
138 paths = client.get_subtitles(MOVIE)
139 assert [os.path.basename(p) for p in paths] == ["en.srt"]
140
141 def test_two_copies_do_not_share_a_folder(self, client):
142 server = FakeServer(_listing(("en", [1, 1])))
143 with _get(server):
144 a = client.get_subtitles(MOVIE)
145 b = client.get_subtitles(EPISODE)
146 assert os.path.dirname(a[0]) != os.path.dirname(b[0])
147
148 def test_one_failed_file_costs_only_that_track(self, client):
149 server = FakeServer(_listing(("en", [1, 1]), ("ko", [2, 1])))
150
151 def flaky(url, timeout=None, headers=None):
152 if url.endswith("/ko.srt"):
153 return _resp(status=503)
154 return server(url, timeout, headers)
155 with _get(flaky):
156 paths = client.get_subtitles(MOVIE)
157 assert [os.path.basename(p) for p in paths] == ["en.srt"]
158
159 def test_an_id_cannot_escape_the_folder(self, client, tmp_path):
160 server = FakeServer({"subtitles": [
161 {"id": "../../evil", "lang": "en", "url": HOST + "/subs/x/evil.srt",
162 "identity": [1, 1]}]})
163 with _get(server):
164 paths = client.get_subtitles(MOVIE)
165 for p in paths:
166 assert os.path.realpath(p).startswith(os.path.realpath(str(tmp_path)))
167
168
169class TestFallsThrough:
170 """None means 'not mine to answer' -- the caller carries on to wyzie."""
171
172 def test_not_a_library_stream(self, client):
173 with patch("lib.clients.subtitle.library.requests.get") as g:
174 assert client.get_subtitles(HOST + "/vidcore/movie/tmdb:1.m3u8") is None
175 g.assert_not_called()
176
177 def test_the_server_is_unreachable(self, client):
178 with patch("lib.clients.subtitle.library.requests.get",
179 side_effect=OSError("no route")):
180 assert client.get_subtitles(MOVIE) is None
181
182 def test_an_older_server_without_the_route(self, client):
183 with patch("lib.clients.subtitle.library.requests.get",
184 return_value=_resp(status=404)):
185 assert client.get_subtitles(MOVIE) is None
186
187 def test_a_copy_with_no_subtitles(self, client):
188 with _get(FakeServer({"subtitles": []})):
189 assert client.get_subtitles(MOVIE) is None
190
191
192# ------------------------------------------------- wired into SubtitleManager
193
194from lib.clients.subtitle.submanager import SubtitleManager # noqa: E402
195
196
197def _settings(**overrides):
198 values = {"wyzie_subtitle_enabled": True, "stremio_subtitle_enabled": False,
199 "deepl_enabled": False, "auto_subtitle_download": False}
200 values.update(overrides)
201 return lambda key, *a, **k: values.get(key, False)
202
203
204def _manager(url):
205 return SubtitleManager({"title": "in library — seekable\nlocal copy",
206 "mode": "movies", "url": url,
207 "ids": {"tmdb_id": "", "imdb_id": "",
208 "original_id": "fxl:639988"}}, MagicMock())
209
210
211class TestManager:
212 def test_a_library_copy_uses_its_own_and_never_asks_wyzie(self, tmp_path):
213 m = _manager(MOVIE)
214 m.library_client.get_subtitles = MagicMock(return_value=["/lib/en.srt"])
215 m.wyzie_client.get_subtitles = MagicMock()
216 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
217 got = m.fetch_subtitles(folder_path=str(tmp_path))
218 assert got == ["/lib/en.srt"]
219 m.wyzie_client.get_subtitles.assert_not_called()
220
221 def test_it_wins_over_a_stale_per_title_cache(self, tmp_path):
222 """The Pi's cache held the unsynced English for this exact title."""
223 (tmp_path / "English.srt").write_text("stale")
224 m = _manager(MOVIE)
225 m.library_client.get_subtitles = MagicMock(return_value=["/lib/en.srt"])
226 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
227 assert m.fetch_subtitles(folder_path=str(tmp_path)) == ["/lib/en.srt"]
228
229 def test_nothing_from_the_library_falls_through_to_wyzie(self, tmp_path):
230 m = _manager(HOST + "/vidcore/movie/tmdb:639988.m3u8")
231 m.library_client.get_subtitles = MagicMock(return_value=None)
232 m.wyzie_client.get_subtitles = MagicMock(return_value=[{"url": "x"}])
233 m.wyzie_client.download_subtitles_batch = MagicMock(return_value=["/w/a.srt"])
234 with patch("lib.clients.subtitle.submanager.get_setting", _settings()):
235 assert m.fetch_subtitles(folder_path=str(tmp_path)) == ["/w/a.srt"]