OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 3667772

Subtitle sync English only; browser silence follows the DEFAULT track (0.28.0)
"focus on english": only English sidecars are measured and repaired (SUBSYNC_LANGUAGES = ("en",)); other languages stay as fetched. ~1.5 min per film instead of ~5. The Watch summary leads with English ("English: corrected (was -2.25 s, speed 1.001) · 31 other languages not checked"). Correction to 0.23.0: browsers play the DEFAULT-flagged audio track, not the first. Measured in Chromium: aac first + not default -> 0 audio bytes; eac3 first + aac default -> audible. The old "first track" test was confounded (its eac3 was first AND default). _browser_audio picks the default-flagged track (first when no flag is known); ffprobe now asks for stream_disposition. Suite 489. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366777208286b4b7581567fecdbd846116a5eb9a
by plankatron <fleeky@gmail.com> · 2026-10-05T02:07:54.000Z

Changed files (3) +155 -15

addon.py +77 -15
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.27.1",
622 "version": "0.28.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -2202,6 +2202,18 @@ SUBS_ORIGINAL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
22022202SUBTITLE_SYNC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
22032203 "subtitle_sync.json")
22042204_subsync_guard = threading.Lock()
2205# Only these subtitle languages are measured and repaired ("focus on english",
2206# 2026-10-04): it is what gets watched, and syncing all ~32 wyzie tracks took
2207# ~5 min per film where English alone takes ~1.5. Others stay as fetched.
2208# None = every language (the mechanics tests use that).
2209SUBSYNC_LANGUAGES: Optional[tuple] = ("en",)
2210
2211
2212def _sidecar_lang(dest: str, path: str) -> str:
2213 """`<stem>.<code>[.<n>].srt` -> code."""
2214 stem = os.path.splitext(os.path.basename(dest))[0] + "."
2215 middle = os.path.basename(path)[len(stem):-len(".srt")]
2216 return re.sub(r"\.\d+$", "", middle)
22052217
22062218
22072219def _subsync_available() -> bool:
……@@ -2315,7 +2327,9 @@ def _sync_subtitles(dest: str) -> dict:
23152327 except Exception:
23162328 return result # unreadable record: touch nothing
23172329 todo = [p for p in _sidecars_of(dest)
2318 if (records.get(os.path.abspath(p)) or {}).get("identity")
2330 if (SUBSYNC_LANGUAGES is None
2331 or _sidecar_lang(dest, p) in SUBSYNC_LANGUAGES)
2332 and (records.get(os.path.abspath(p)) or {}).get("identity")
23192333 != _file_identity(p)]
23202334 result["tracks"] = len(todo)
23212335 if not todo:
……@@ -2406,6 +2420,8 @@ def _subtitle_summary(dest: str) -> str:
24062420 except Exception:
24072421 records = {}
24082422 stem = os.path.splitext(os.path.abspath(dest))[0] + "."
2423 if SUBSYNC_LANGUAGES is not None:
2424 return _subtitle_summary_focused(dest, present, records, stem)
24092425 counts = {"corrected": 0, "in_sync": 0, "unchecked": 0}
24102426 for p in present:
24112427 rec = records.get(os.path.abspath(p)) or {}
……@@ -2423,6 +2439,39 @@ def _subtitle_summary(dest: str) -> str:
24232439 return " · ".join(parts)
24242440
24252441
2442def _subtitle_summary_focused(dest: str, present: list, records: dict,
2443 stem: str) -> str:
2444 """English first: what the sync did to it, then how many other languages
2445 sit beside the copy unchecked."""
2446 focus = [p for p in present if _sidecar_lang(dest, p) in SUBSYNC_LANGUAGES]
2447 others = len(present) - len(focus)
2448 removed = [k for k, v in records.items()
2449 if k.startswith(stem) and v.get("status") == "unfixable"
2450 and _sidecar_lang(dest, k) in SUBSYNC_LANGUAGES]
2451
2452 def describe(p: str) -> str:
2453 rec = records.get(os.path.abspath(p)) or {}
2454 if rec.get("identity") != _file_identity(p):
2455 return "not yet checked"
2456 if rec.get("status") == "corrected":
2457 return (f"corrected (was {rec['offset']:+.2f} s, "
2458 f"speed {rec['scale']:.3f})").replace("+", "")
2459 return {"in_sync": "in sync"}.get(rec.get("status"), "not yet checked")
2460
2461 if len(focus) == 1:
2462 head = f"English: {describe(focus[0])}"
2463 elif focus:
2464 states = [describe(p).split(" (")[0] for p in focus]
2465 head = f"English ({len(focus)}): " + " · ".join(
2466 f"{states.count(x)} {x}" for x in dict.fromkeys(states))
2467 else:
2468 head = ("English: removed (out of sync)" if removed else "no English")
2469 if others:
2470 head += (f" · {others} other language{'s' if others != 1 else ''} "
2471 "not checked")
2472 return head
2473
2474
24262475def _fetch_subtitles_for(dest: str, meta: dict, season: int, episode: int) -> None:
24272476 """Put whatever subtitles exist beside a published copy. Never raises.
24282477
……@@ -4679,10 +4728,10 @@ def web_cancel():
46794728# ---------------------------------- watch -----------------------------------
46804729# Play a library copy in the browser to judge what it actually is.
46814730#
4682# Audio a browser cannot decode plays as SILENCE, and it is the FIRST audio
4683# track that decides it. Measured 2026-10-04 in Chromium: canPlayType for ec-3
4684# and ac-3 is "", an eac3-only file decoded 0 audio bytes, and an eac3 + aac
4685# file ALSO decoded 0 -- no fallback to the later track it could have played.
4731# Audio a browser cannot decode plays as SILENCE, and it is the DEFAULT-flagged
4732# track that decides it (see _browser_audio). Measured 2026-10-04 in Chromium:
4733# canPlayType for ec-3 and ac-3 is "", an eac3-only file decoded 0 audio bytes,
4734# and the browser never switches to another track it could have played.
46864735# Jellyfin gets sound out of these by re-encoding the audio into an HLS stream
46874736# per viewer (labs#13 has the trace); that machinery is deliberately not
46884737# rebuilt here. Picture-only is enough to judge picture quality, and the page
……@@ -4697,7 +4746,7 @@ def _ffprobe_file(path: str) -> Optional[dict]:
46974746 ["ffprobe", "-v", "error", "-show_entries",
46984747 "stream=index,codec_type,codec_name,profile,width,height,"
46994748 "avg_frame_rate,bit_rate,channels,pix_fmt:stream_tags=language"
4700 ":format=duration,size,bit_rate",
4749 ":stream_disposition=default:format=duration,size,bit_rate",
47014750 "-of", "json", path],
47024751 capture_output=True, text=True, timeout=60)
47034752 return json.loads(out.stdout) if out.returncode == 0 else None
……@@ -4720,6 +4769,17 @@ def _quality(path: str) -> Optional[dict]:
47204769 return _quality_cache[key]
47214770
47224771
4772def _browser_audio(audio: list[dict]) -> Optional[dict]:
4773 """The track a browser plays: the DEFAULT-flagged one, not the first.
4774 Measured 2026-10-04 in Chromium: aac first + not default decoded 0 audio
4775 bytes; eac3 first + aac default played the aac. With no flag recorded
4776 (HdHub's probe does not ask for it), the first track is the best guess."""
4777 for a in audio:
4778 if (a.get("disposition") or {}).get("default"):
4779 return a
4780 return audio[0] if audio else None
4781
4782
47234783def _fps(rate: Optional[str]) -> Optional[float]:
47244784 try:
47254785 num, den = (rate or "").split("/")
……@@ -4815,11 +4875,12 @@ def _quality_html(path: str, meta: Optional[dict]) -> str:
48154875 rows.append(("Source", _source_of(path) or "not recorded"))
48164876 rows.append(("Subtitles", _subtitle_summary(path)))
48174877 warn = ""
4818 if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO:
4819 warn = ("<p class=warn>This copy plays without sound here: its first "
4820 f"audio track is {html.escape(audio[0].get('codec_name'))}, "
4821 "which browsers cannot decode (and they do not fall back to a "
4822 "later track). Kodi and Jellyfin play it with sound.</p>")
4878 played = _browser_audio(audio)
4879 if played and played.get("codec_name") in BROWSER_SILENT_AUDIO:
4880 warn = ("<p class=warn>This copy plays without sound here: its default "
4881 f"audio track is {html.escape(played.get('codec_name'))}, "
4882 "which browsers cannot decode (and they do not switch to "
4883 "another track). Kodi and Jellyfin play it with sound.</p>")
48234884 return warn + "<table>" + "".join(
48244885 f"<tr><td class=mute>{k}</td><td>{html.escape(str(v))}</td></tr>"
48254886 for k, v in rows) + "</table>"
……@@ -5096,9 +5157,10 @@ def _preview_quality_html(pv: dict) -> str:
50965157 lang = (a.get("tags") or {}).get("language") or "und"
50975158 rows.append((f"Audio {i}", f"{a.get('codec_name')} · {lang}"))
50985159 warn = ""
5099 if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO:
5100 warn = ("<p class=warn>This preview plays without sound: the first "
5101 f"audio track is {html.escape(audio[0].get('codec_name'))}, "
5160 played = _browser_audio(audio)
5161 if played and played.get("codec_name") in BROWSER_SILENT_AUDIO:
5162 warn = ("<p class=warn>This preview plays without sound: the default "
5163 f"audio track is {html.escape(played.get('codec_name'))}, "
51025164 "which browsers cannot decode. The copy keeps it.</p>")
51035165 return warn + "<table>" + "".join(
51045166 f"<tr><td class=mute>{k}</td><td>{html.escape(str(v))}</td></tr>"
test_subtitle_sync.py +43 -0
……@@ -37,6 +37,9 @@ def lib(tmp_path, monkeypatch):
3737 monkeypatch.setattr(addon, "SUBS_ORIGINAL_DIR", str(tmp_path / "subs-original"))
3838 monkeypatch.setattr(addon, "SUBTITLE_SYNC_FILE", str(tmp_path / "sync.json"))
3939 monkeypatch.setattr(addon, "_subsync_available", lambda: True)
40 # The mechanics below use several languages; production syncs English
41 # only (SUBSYNC_LANGUAGES), which test_only_english_is_synced pins.
42 monkeypatch.setattr(addon, "SUBSYNC_LANGUAGES", None)
4043 monkeypatch.setattr(addon, "_subsync_reference",
4144 lambda dest, workdir: os.path.join(workdir, "ref.npz"))
4245 notified = []
……@@ -278,3 +281,43 @@ def test_every_output_path_keeps_the_srt_extension(lib, monkeypatch):
278281 monkeypatch.setattr(addon, "_subsync_measure", record)
279282 addon._sync_subtitles(dest)
280283 assert outs and all(o and o.endswith(".srt") for o in outs)
284
285
286# --- English only (2026-10-04: "focus on english") ---------------------------
287
288def test_only_english_is_synced_by_default(lib, monkeypatch):
289 dest, _ = lib
290 monkeypatch.setattr(addon, "SUBSYNC_LANGUAGES", ("en",))
291 _srt(dest, "en")
292 _srt(dest, "en.2")
293 fr = _srt(dest, "fr")
294 measure, calls = _measure({"Film (2025).en.srt": (0.0, 1.0),
295 "Film (2025).en.2.srt": (0.0, 1.0)})
296 monkeypatch.setattr(addon, "_subsync_measure", measure)
297 result = addon._sync_subtitles(dest)
298 assert sorted(calls) == ["Film (2025).en.2.srt", "Film (2025).en.srt"]
299 assert result["tracks"] == 2
300 assert open(fr).read().startswith("1\n") # untouched
301
302
303def test_english_only_extracts_nothing_when_there_is_no_english(lib, monkeypatch):
304 dest, _ = lib
305 monkeypatch.setattr(addon, "SUBSYNC_LANGUAGES", ("en",))
306 _srt(dest, "fr")
307 monkeypatch.setattr(addon, "_subsync_reference",
308 lambda d, w: pytest.fail("extracted speech with no English track"))
309 assert addon._sync_subtitles(dest)["tracks"] == 0
310
311
312def test_the_summary_says_other_languages_are_not_checked(lib, monkeypatch):
313 dest, _ = lib
314 monkeypatch.setattr(addon, "SUBSYNC_LANGUAGES", ("en",))
315 _srt(dest, "en")
316 _srt(dest, "fr")
317 _srt(dest, "de")
318 measure, _ = _measure({"Film (2025).en.srt": (-2.25, 1.001),
319 "Film (2025).en.srt.synced": (0.0, 1.0)})
320 monkeypatch.setattr(addon, "_subsync_measure", measure)
321 addon._sync_subtitles(dest)
322 assert addon._subtitle_summary(dest) == \
323 "English: corrected (was -2.25 s, speed 1.001) · 2 other languages not checked"
test_web_watch.py +35 -0
……@@ -206,3 +206,38 @@ def test_a_partial_copy_is_neither_served_nor_offered(client, monkeypatch):
206206 _hold("movie", tmdb="2")
207207 assert client.get("/web/file/movie/2.mp4").status_code == 404
208208 assert client.get("/web/watch/movie/2").status_code == 404
209
210
211# Measured 2026-10-04 in Chromium: the browser plays the DEFAULT-flagged audio
212# track, not the first. aac first + not default = 0 audio bytes; eac3 first +
213# aac default = audible. (The earlier "first track" wording was confounded:
214# that test's eac3 track was both first and default.)
215
216PROBE_EAC3_FIRST_AAC_DEFAULT = {
217 "format": {"duration": "1800.0"},
218 "streams": [
219 {"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
220 {"index": 1, "codec_type": "audio", "codec_name": "eac3", "channels": 6,
221 "disposition": {"default": 0}, "tags": {"language": "eng"}},
222 {"index": 2, "codec_type": "audio", "codec_name": "aac", "channels": 2,
223 "disposition": {"default": 1}, "tags": {"language": "eng"}}]}
224
225PROBE_AAC_FIRST_EAC3_DEFAULT = {
226 "format": {"duration": "1800.0"},
227 "streams": [
228 {"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
229 {"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2,
230 "disposition": {"default": 0}, "tags": {"language": "eng"}},
231 {"index": 2, "codec_type": "audio", "codec_name": "eac3", "channels": 6,
232 "disposition": {"default": 1}, "tags": {"language": "eng"}}]}
233
234
235def test_silence_follows_the_default_track_not_the_first(client, isolated):
236 _hold("series", 2, 6)
237 isolated["value"] = PROBE_EAC3_FIRST_AAC_DEFAULT
238 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
239 assert "without sound" not in body
240 addon._quality_cache.clear()
241 isolated["value"] = PROBE_AAC_FIRST_EAC3_DEFAULT
242 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
243 assert "without sound" in body and "default audio track" in body