OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 77e96e6

Serve a library copy's own subtitles by its id (0.30.0)
The sync corrects the sidecars beside a copy, but only Jellyfin reads that folder. Kodi plays the copy through /mp4/ and Jacktook fetched wyzie into a per-TMDB cache: on 2026-10-18 the Pi's No Other Choice English.srt was byte-identical to the unsynced original, the drift the sync had fixed. /subs/<kind>/<ident>.json lists the held copy's sidecars (id, lang, url, identity) and each url serves that file. Never fetches; a name that is not one of the copy's sidecars is 404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77e96e6135947485253ba4ec981070393eea5196
by plankatron <fleeky@gmail.com> · 2026-10-19T00:13:25.000Z

Changed files (2) +193 -1

addon.py +52 -1
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.29.0",
622 "version": "0.30.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -3055,6 +3055,57 @@ def mp4_series(ident: str):
30553055 return _serve_mp4("series", m.group(1), int(m.group(2)), int(m.group(3)))
30563056
30573057
3058# ------------------------- a library copy's own subtitles -------------------
3059# The sync (labs#13) corrects the sidecars beside a copy, and only Jellyfin
3060# reads that folder. Kodi plays the copy through /mp4/, so Jacktook fetched
3061# wyzie itself into a per-TMDB cache: on 2026-10-18 the Pi's No Other Choice
3062# English.srt was byte-identical to the UNSYNCED original -- the drift the sync
3063# had already fixed. These routes hand a player the file's own sidecars under
3064# the same id the file is served by. Like /web/file/, they never fetch.
3065
3066def _subs_target(kind: str, ident: str) -> Optional[tuple]:
3067 """(kind, ident, season, episode) from a /subs/ ident, or None."""
3068 if kind == "movie" and re.fullmatch(VIDCORE_IDENT, ident):
3069 return kind, ident, 0, 0
3070 m = re.fullmatch(rf"({VIDCORE_IDENT}):(\d+):(\d+)", ident)
3071 if kind == "series" and m:
3072 return kind, m.group(1), int(m.group(2)), int(m.group(3))
3073 return None
3074
3075
3076def _subs_by_id(kind: str, ident: str) -> dict:
3077 """{id: sidecar path} for the held copy; id is `<code>[.<n>]`."""
3078 target = _subs_target(kind, ident)
3079 if not target:
3080 abort(404)
3081 path = _playable_path(*target)
3082 if not path:
3083 return {}
3084 stem = os.path.splitext(os.path.basename(path))[0] + "."
3085 return {os.path.basename(p)[len(stem):-len(".srt")]: p
3086 for p in _sidecars_of(path)}
3087
3088
3089@app.route("/subs/<kind>/<path:ident>.json")
3090def subs_list(kind: str, ident: str):
3091 out = []
3092 for sid, p in sorted(_subs_by_id(kind, ident).items()):
3093 out.append({"id": sid, "lang": re.sub(r"\.\d+$", "", sid),
3094 "url": f"http://{request.host}/subs/{kind}/{ident}/{sid}.srt",
3095 # Lets a client tell a re-synced file from the one it holds.
3096 "identity": _file_identity(p)})
3097 return jsonify({"subtitles": out})
3098
3099
3100@app.route("/subs/<kind>/<path:ident>/<sid>.srt")
3101def subs_file(kind: str, ident: str, sid: str):
3102 # Looked up among the copy's own sidecars, never joined onto a path.
3103 path = _subs_by_id(kind, ident).get(sid)
3104 if not path:
3105 abort(404)
3106 return send_file(path, mimetype="application/x-subrip")
3107
3108
30583109# ------------------------------ HdHub (direct files) ------------------------
30593110# The second live source, and the first that hands over FILES rather than a
30603111# player. Found 2026-09-28 in cinewave's bundle (watch.cinewave.qzz.io), where it
test_subtitle_route.py +141 -0
……@@ -0,0 +1,141 @@
1"""The library copy's OWN subtitles, for players that cannot see its folder.
2
32026-10-18: No Other Choice's English drifted in Kodi although the sync had
4corrected it on 10-04. The corrected file only ever reached Jellyfin, which
5reads the folder. Kodi plays the copy over HTTP through /mp4/ and Jacktook
6fetched wyzie itself into a per-TMDB cache on the Pi -- proven by md5: the Pi's
7English.srt (025fc304...) was byte-identical to the UNSYNCED original in
8subs-original/, not the corrected sidecar (37840b79...).
9
10So the subtitles that belong to a file are served by the same id the file is:
11/subs/<kind>/<ident>.json lists them (Stremio's subtitle object: id, url,
12lang), and each url serves the sidecar itself. Live, not baked into the stream
13row, because Jacktook caches the row for 24h and a sync can land in between.
14
15Like /web/file/, this must never fetch anything: /mp4/ queues the next episode
16and starts an ingest, a subtitle lookup must not.
17
18Run: .venv/bin/python3 -m pytest test_subtitle_route.py -q
19"""
20
21import os
22
23import pytest
24
25import addon
26
27
28TMDB = {
29 "tv/1": {"name": "Show", "first_air_date": "2020-01-01", "seasons": []},
30 "tv/1/season/2/episode/6": {"name": "Six", "runtime": 30},
31 "tv/1/season/2/episode/7": {"name": "Seven", "runtime": 30},
32 "movie/2": {"title": "Film", "release_date": "2021-05-05", "runtime": 30},
33}
34
35
36@pytest.fixture(autouse=True)
37def isolated(tmp_path, monkeypatch):
38 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path))
39 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json"))
40 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", str(tmp_path / "verified.json"))
41 monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path / "mp4-cache"))
42 monkeypatch.setattr(addon, "_duration_cache", {})
43 monkeypatch.setattr(addon, "_library_meta_cache", {})
44 monkeypatch.setattr(addon, "_mp4_jobs", {})
45 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: TMDB[path])
46 monkeypatch.setattr(addon, "_media_duration", lambda p: 1800.0)
47
48 def refuse(*a, **k):
49 raise AssertionError("a subtitle lookup started a download")
50 monkeypatch.setattr(addon, "_start_mp4_job", refuse)
51 monkeypatch.setattr(addon, "_queue_next_episode", refuse)
52 monkeypatch.setattr(addon, "_ensure_ingest", refuse)
53 monkeypatch.setattr(addon, "_resolve_vidcore", refuse)
54 yield
55
56
57@pytest.fixture
58def client():
59 return addon.app.test_client()
60
61
62def _hold(kind, season=0, episode=0, tmdb="1", subs=("en",)):
63 meta = addon._library_meta(kind, f"tmdb:{tmdb}", season, episode)
64 path = addon._library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
65 season, episode, meta["episode_title"])
66 os.makedirs(os.path.dirname(path), exist_ok=True)
67 with open(path, "wb") as f:
68 f.write(b"0123456789" * 100)
69 stem = os.path.splitext(path)[0]
70 for code in subs:
71 with open(f"{stem}.{code}.srt", "w") as f:
72 f.write(f"1\n00:00:01,000 --> 00:00:02,000\n{code}\n")
73 return path
74
75
76def test_a_held_movie_lists_its_sidecars(client):
77 _hold("movie", tmdb="2", subs=("en", "ko"))
78 r = client.get("/subs/movie/tmdb:2.json")
79 assert r.status_code == 200
80 subs = r.get_json()["subtitles"]
81 assert sorted(s["lang"] for s in subs) == ["en", "ko"]
82 for s in subs:
83 assert s["id"] and s["url"].startswith("http://")
84
85
86def test_each_listed_url_serves_that_sidecar_byte_for_byte(client):
87 path = _hold("movie", tmdb="2", subs=("en", "pt-BR"))
88 for s in client.get("/subs/movie/tmdb:2.json").get_json()["subtitles"]:
89 body = client.get(s["url"].split("localhost", 1)[1]).get_data()
90 with open(f"{os.path.splitext(path)[0]}.{s['lang']}.srt", "rb") as f:
91 assert body == f.read()
92
93
94def test_a_held_episode_lists_its_sidecars(client):
95 _hold("series", 2, 6, subs=("en",))
96 subs = client.get("/subs/series/tmdb:1:2:6.json").get_json()["subtitles"]
97 assert [s["lang"] for s in subs] == ["en"]
98
99
100def test_a_second_track_in_one_language_keeps_its_own_id(client):
101 """`<stem>.en.2.srt` is a different file from `<stem>.en.srt`; both are
102 offered, and each url serves its own."""
103 path = _hold("movie", tmdb="2", subs=("en", "en.2"))
104 subs = client.get("/subs/movie/tmdb:2.json").get_json()["subtitles"]
105 assert sorted(s["id"] for s in subs) == ["en", "en.2"]
106 assert all(s["lang"] == "en" for s in subs)
107 two = next(s for s in subs if s["id"] == "en.2")
108 body = client.get(two["url"].split("localhost", 1)[1]).get_data()
109 with open(f"{os.path.splitext(path)[0]}.en.2.srt", "rb") as f:
110 assert body == f.read()
111
112
113def test_the_list_carries_each_file_identity(client):
114 """So a client can tell a re-synced file from the one it cached."""
115 path = _hold("movie", tmdb="2", subs=("en",))
116 (s,) = client.get("/subs/movie/tmdb:2.json").get_json()["subtitles"]
117 st = os.stat(f"{os.path.splitext(path)[0]}.en.srt")
118 assert s["identity"] == [st.st_size, int(st.st_mtime)]
119
120
121def test_not_held_is_an_empty_list_and_fetches_nothing(client):
122 r = client.get("/subs/series/tmdb:1:2:7.json")
123 assert r.status_code == 200
124 assert r.get_json() == {"subtitles": []}
125
126
127def test_held_without_sidecars_is_an_empty_list(client):
128 _hold("movie", tmdb="2", subs=())
129 assert client.get("/subs/movie/tmdb:2.json").get_json() == {"subtitles": []}
130
131
132def test_a_name_that_is_not_one_of_its_sidecars_is_404(client):
133 _hold("movie", tmdb="2", subs=("en",))
134 assert client.get("/subs/movie/tmdb:2/de.srt").status_code == 404
135 assert client.get("/subs/movie/tmdb:2/..%2F..%2Fverified.json.srt").status_code == 404
136
137
138def test_bad_ids_are_404(client):
139 assert client.get("/subs/movie/x.json").status_code == 404
140 assert client.get("/subs/series/tmdb:1.json").status_code == 404
141 assert client.get("/subs/anime/tmdb:1.json").status_code == 404