OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / d1f87d6

Subtitle sync on the player's timeline; redo results from the old reference (0.32.0)
ffsubsync pipes raw PCM out of ffmpeg, which drops gaps in the audio. No Other Choice's audio starts at 9.254 s, so the 10-04 reference ran ~9.3 s early: English "corrected" by -2.25 s needed +7.01 s, and 23 tracks that were in sync (fr re-measures +0.33 s) were moved by -8.9 s. The reference is now built from audio padded to its timestamps (aresample=async=1:first_pts=0). The full file and three timestamp-seeked windows agree: +7.01 / +7.03 s, scale 1.001. Results carry ref=2. A corrected or withdrawn track from the old reference is restored from its original (the old correction is kept in the store) and measured again, in any language the old pass touched. Kept tracks stay the user's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d1f87d610acda24606d18453e5382146e3c26d1a
by plankatron <fleeky@gmail.com> · 2026-10-19T00:42:11.000Z

Changed files (2) +288 -12

addon.py +65 -12
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.31.0",
622 "version": "0.32.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -2275,6 +2275,10 @@ _subsync_guard = threading.Lock()
22752275# ~5 min per film where English alone takes ~1.5. Others stay as fetched.
22762276# None = every language (the mechanics tests use that).
22772277SUBSYNC_LANGUAGES: Optional[tuple] = ("en",)
2278# Which reference a result was measured against. 2 = audio padded to its
2279# timestamps (2026-10-18, see _subsync_reference). A result without it came
2280# from the gap-dropping reference and is measured again from its original.
2281SUBSYNC_REF_VERSION = 2
22782282
22792283
22802284def _sidecar_lang(dest: str, path: str) -> str:
……@@ -2305,17 +2309,25 @@ def _subsync_reference(dest: str, workdir: str) -> Optional[str]:
23052309 """Extract the copy's speech ONCE into workdir; the npz is the reference
23062310 every track is then measured against (~6 s each instead of ~70 s).
23072311
2308 Run from a symlink inside workdir, because --serialize-speech writes the
2309 npz beside its input -- which would otherwise be the library folder.
2312 The audio goes through a WAV padded to its own timestamps first. ffsubsync
2313 pipes raw PCM out of ffmpeg, which has no timestamps, so a gap in the audio
2314 is dropped and everything after it lands early. No Other Choice's audio
2315 starts at 9.254 s: the 10-04 reference ran ~9.3 s early, "corrected" the
2316 English by -2.25 s when it needed +7.01 s, and moved 23 tracks that were
2317 in sync by -8.9 s. Padded, the full file and three timestamp-seeked
2318 windows agree (+7.01 / +7.03 s). The WAV sits in workdir, which is also
2319 where --serialize-speech writes the npz.
23102320 """
23112321 sidecars = _sidecars_of(dest)
23122322 if not sidecars:
23132323 return None
2314 link = os.path.join(workdir, "ref" + os.path.splitext(dest)[1])
2324 wav = os.path.join(workdir, "ref.wav")
23152325 try:
2316 os.symlink(dest, link)
2317 subprocess.run([SUBSYNC_BIN, link, "-i", sidecars[0], "-o", os.devnull,
2318 "--serialize-speech"], cwd=workdir,
2326 subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", dest, "-vn",
2327 "-ac", "1", "-ar", "16000",
2328 "-af", "aresample=async=1:first_pts=0", wav],
2329 check=True, capture_output=True, timeout=SUBSYNC_TIMEOUT)
2330 subprocess.run([SUBSYNC_BIN, wav, "--serialize-speech"], cwd=workdir,
23192331 capture_output=True, text=True, timeout=SUBSYNC_TIMEOUT)
23202332 except Exception as exc:
23212333 log.warning("subsync: speech extraction failed for %s (%s)",
……@@ -2379,6 +2391,36 @@ def _keep_original(path: str) -> str:
23792391 return candidate
23802392
23812393
2394def _restore_stale_originals(dest: str, records: dict) -> bool:
2395 """Put back the original of every track an older reference corrected or
2396 withdrew, so it is measured afresh. The superseded correction goes to the
2397 store first: nothing is deleted. A track the user kept is theirs.
2398 Returns whether the library changed. Never raises."""
2399 folder = os.path.dirname(os.path.abspath(dest))
2400 stem = os.path.splitext(os.path.basename(dest))[0]
2401 pattern = re.compile(re.escape(stem) + r"\.[^./\\]+(\.\d+)?\.srt$")
2402 changed = False
2403 for key, rec in records.items():
2404 if (os.path.dirname(key) != folder
2405 or not pattern.fullmatch(os.path.basename(key))
2406 or rec.get("ref") == SUBSYNC_REF_VERSION
2407 or rec.get("status") not in ("corrected", "unfixable")
2408 or not os.path.exists(rec.get("original") or "")):
2409 continue
2410 try:
2411 if os.path.exists(key):
2412 _keep_original(key)
2413 part = _part_path(key)
2414 shutil.copyfile(rec["original"], part)
2415 os.replace(part, key)
2416 changed = True
2417 log.info("subsync: %s restored from its original for re-measure",
2418 os.path.basename(key))
2419 except Exception as exc:
2420 log.warning("subsync: %s not restored (%s)", os.path.basename(key), exc)
2421 return changed
2422
2423
23822424def _sync_subtitles(dest: str) -> dict:
23832425 """Measure and repair every not-yet-checked sidecar beside one copy.
23842426
……@@ -2394,15 +2436,25 @@ def _sync_subtitles(dest: str) -> dict:
23942436 records = _load_sync()
23952437 except Exception:
23962438 return result # unreadable record: touch nothing
2439 changed = _restore_stale_originals(dest, records)
2440
2441 def current(p: str) -> bool:
2442 rec = records.get(os.path.abspath(p)) or {}
2443 return rec.get("identity") == _file_identity(p) and (
2444 rec.get("status") == "kept" or rec.get("ref") == SUBSYNC_REF_VERSION)
2445
2446 # In scope: the focus languages, plus any track an earlier pass measured
2447 # -- the all-language pass of 10-04 left its results on every language.
23972448 todo = [p for p in _sidecars_of(dest)
23982449 if (SUBSYNC_LANGUAGES is None
2399 or _sidecar_lang(dest, p) in SUBSYNC_LANGUAGES)
2400 and (records.get(os.path.abspath(p)) or {}).get("identity")
2401 != _file_identity(p)]
2450 or _sidecar_lang(dest, p) in SUBSYNC_LANGUAGES
2451 or os.path.abspath(p) in records)
2452 and not current(p)]
24022453 result["tracks"] = len(todo)
24032454 if not todo:
2455 if changed:
2456 _notify_jellyfin(dest, "Modified")
24042457 return result
2405 changed = False
24062458 fresh: dict[str, dict] = {}
24072459 with tempfile.TemporaryDirectory(prefix="subsync-") as work:
24082460 ref = _subsync_reference(dest, work)
……@@ -2422,7 +2474,8 @@ def _sync_subtitles(dest: str) -> dict:
24222474 if not m:
24232475 result["unmeasured"] += 1
24242476 continue
2425 entry = {"offset": m["offset"], "scale": m["scale"], "ts": time.time()}
2477 entry = {"offset": m["offset"], "scale": m["scale"], "ts": time.time(),
2478 "ref": SUBSYNC_REF_VERSION}
24262479 try:
24272480 if _in_tolerance(m):
24282481 entry["status"] = "in_sync"
test_subsync_reference.py +223 -0
……@@ -0,0 +1,223 @@
1"""The sync reference must be on the PLAYER's timeline (2026-10-18).
2
3ffsubsync extracts speech by piping raw PCM out of ffmpeg, and raw PCM has no
4timestamps: a gap in the audio is simply dropped. No Other Choice's audio
5stream starts at 9.254 s (video at 0), so the 10-04 reference ran ~9.3 s early
6and every measurement inherited it:
7 - the English "corrected" by -2.25 s actually needed +7.01 s (scale 1.001)
8 - the "-8.9 s family" of 23 tracks was IN SYNC (fr re-measures +0.33 s) --
9 the sync moved them 9 s off
10Two independent references agree on the right answer: audio padded to its
11timestamps (aresample=async=1:first_pts=0) gave +7.01 s, and three 5-minute
12windows seeked by timestamp gave +7.03 s.
13
14So the reference is built from gap-padded audio, results carry the reference
15version they were measured with, and a result from the old reference is
16measured again from the track's ORIGINAL -- the old correction is kept in the
17store, never deleted. A track the user kept stays theirs.
18
19Run: .venv/bin/python3 -m pytest test_subsync_reference.py -q
20"""
21
22import json
23import os
24import subprocess
25
26import pytest
27
28import addon
29
30REAL_REFERENCE = addon._subsync_reference
31
32
33@pytest.fixture
34def lib(tmp_path, monkeypatch):
35 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib"))
36 monkeypatch.setattr(addon, "SUBS_ORIGINAL_DIR", str(tmp_path / "subs-original"))
37 monkeypatch.setattr(addon, "SUBTITLE_SYNC_FILE", str(tmp_path / "sync.json"))
38 monkeypatch.setattr(addon, "_subsync_available", lambda: True)
39 monkeypatch.setattr(addon, "SUBSYNC_LANGUAGES", ("en",))
40 monkeypatch.setattr(addon, "_notify_jellyfin", lambda p, t: None)
41 monkeypatch.setattr(addon, "_subsync_reference",
42 lambda dest, workdir: os.path.join(workdir, "ref.npz"))
43 dest = tmp_path / "lib" / "Movies" / "Film (2025) [tmdbid-1]" / "Film (2025).mp4"
44 dest.parent.mkdir(parents=True)
45 dest.write_bytes(b"video")
46 return str(dest)
47
48
49def _write(path, body):
50 os.makedirs(os.path.dirname(path), exist_ok=True)
51 with open(path, "w") as f:
52 f.write(body)
53 return path
54
55
56def _sidecar(dest, lang):
57 return os.path.splitext(dest)[0] + f".{lang}.srt"
58
59
60def _record(records):
61 with open(addon.SUBTITLE_SYNC_FILE, "w") as f:
62 json.dump(records, f)
63
64
65def _records():
66 with open(addon.SUBTITLE_SYNC_FILE) as f:
67 return json.load(f)
68
69
70def _measure_by_body(table):
71 """(offset, scale) chosen by the track's CONTENT, so a test can tell the
72 original from the old correction; writes the corrected output."""
73 seen = []
74
75 def measure(ref, srt_in, srt_out):
76 with open(srt_in) as f:
77 body = f.read()
78 seen.append(body)
79 offset, scale = table.get(body, (0.0, 1.0))
80 if srt_out:
81 with open(srt_out, "w") as f:
82 f.write("fixed")
83 return {"offset": offset, "scale": scale, "score": 1.0}
84 return measure, seen
85
86
87# ------------------------------------------------------------ the reference
88
89def test_the_reference_is_built_from_audio_padded_to_its_timestamps(lib, tmp_path,
90 monkeypatch):
91 _write(_sidecar(lib, "en"), "x")
92 monkeypatch.setattr(addon, "_subsync_reference", REAL_REFERENCE)
93 calls = []
94
95 def fake_run(cmd, **kw):
96 calls.append(cmd)
97 if os.path.basename(cmd[0]) == "ffsubsync":
98 np_path = os.path.join(kw["cwd"], "ref.npz")
99 open(np_path, "wb").close()
100 return subprocess.CompletedProcess(cmd, 0, "", "")
101 monkeypatch.setattr(addon.subprocess, "run", fake_run)
102 work = tmp_path / "work"
103 work.mkdir()
104 ref = addon._subsync_reference(lib, str(work))
105 assert ref == str(work / "ref.npz")
106 ffmpeg, subsync = calls
107 assert ffmpeg[0] == "ffmpeg" and lib in ffmpeg
108 assert "aresample=async=1:first_pts=0" in ffmpeg
109 wav = ffmpeg[-1]
110 assert wav.endswith(".wav") and os.path.dirname(wav) == str(work)
111 assert subsync[1] == wav and "--serialize-speech" in subsync
112
113
114def test_an_audio_extraction_failure_is_no_reference(lib, tmp_path, monkeypatch):
115 _write(_sidecar(lib, "en"), "x")
116 monkeypatch.setattr(addon, "_subsync_reference", REAL_REFERENCE)
117
118 def boom(cmd, **kw):
119 raise subprocess.CalledProcessError(1, cmd)
120 monkeypatch.setattr(addon.subprocess, "run", boom)
121 assert addon._subsync_reference(lib, str(tmp_path)) is None
122
123
124# ------------------------------------------- results from the old reference
125
126def test_new_results_carry_the_reference_version(lib, monkeypatch):
127 en = _write(_sidecar(lib, "en"), "orig")
128 measure, _ = _measure_by_body({})
129 monkeypatch.setattr(addon, "_subsync_measure", measure)
130 addon._sync_subtitles(lib)
131 assert _records()[os.path.abspath(en)]["ref"] == addon.SUBSYNC_REF_VERSION
132
133
134def test_a_correction_from_the_old_reference_is_redone_from_the_original(
135 lib, tmp_path, monkeypatch):
136 en = _write(_sidecar(lib, "en"), "old-fix")
137 orig = _write(str(tmp_path / "subs-original" / "en.srt.2"), "orig")
138 _record({os.path.abspath(en): {"status": "corrected", "offset": -2.25,
139 "scale": 1.001, "original": orig,
140 "identity": addon._file_identity(en)}})
141 measure, seen = _measure_by_body({"orig": (7.01, 1.001), "fixed": (0.0, 1.0)})
142 monkeypatch.setattr(addon, "_subsync_measure", measure)
143 addon._sync_subtitles(lib)
144 assert seen[0] == "orig", "measured the old correction, not the original"
145 rec = _records()[os.path.abspath(en)]
146 assert rec["status"] == "corrected" and rec["offset"] == 7.01
147 assert rec["ref"] == addon.SUBSYNC_REF_VERSION
148 with open(en) as f:
149 assert f.read() == "fixed"
150 kept = []
151 for root, _, files in os.walk(addon.SUBS_ORIGINAL_DIR):
152 for n in files:
153 with open(os.path.join(root, n)) as f:
154 kept.append(f.read())
155 assert "old-fix" in kept, "the superseded correction was not kept"
156 assert "orig" in kept
157
158
159def test_a_track_the_old_reference_withdrew_comes_back_and_is_measured(
160 lib, tmp_path, monkeypatch):
161 en = _sidecar(lib, "en") # not in the library any more
162 orig = _write(str(tmp_path / "subs-original" / "en.srt.2"), "orig")
163 _record({os.path.abspath(en): {"status": "unfixable", "offset": 9.0,
164 "scale": 1.0, "original": orig,
165 "identity": None}})
166 measure, _ = _measure_by_body({"orig": (0.1, 1.0)})
167 monkeypatch.setattr(addon, "_subsync_measure", measure)
168 addon._sync_subtitles(lib)
169 assert os.path.exists(en)
170 assert _records()[os.path.abspath(en)]["status"] == "in_sync"
171
172
173def test_an_old_in_sync_verdict_is_measured_again(lib, monkeypatch):
174 en = _write(_sidecar(lib, "en"), "orig")
175 _record({os.path.abspath(en): {"status": "in_sync", "offset": 0.1,
176 "scale": 1.0,
177 "identity": addon._file_identity(en)}})
178 measure, seen = _measure_by_body({"orig": (9.25, 1.0), "fixed": (0.0, 1.0)})
179 monkeypatch.setattr(addon, "_subsync_measure", measure)
180 addon._sync_subtitles(lib)
181 assert seen and _records()[os.path.abspath(en)]["status"] == "corrected"
182
183
184def test_another_language_the_old_reference_touched_is_redone_too(
185 lib, tmp_path, monkeypatch):
186 """English-only scope came AFTER 10-04's all-language pass, which moved
187 23 good No Other Choice tracks by -8.9 s. Our own damage is repaired
188 whatever the language."""
189 fr = _write(_sidecar(lib, "fr"), "old-fix")
190 orig = _write(str(tmp_path / "subs-original" / "fr.srt.2"), "orig")
191 _record({os.path.abspath(fr): {"status": "corrected", "offset": -8.93,
192 "scale": 1.0, "original": orig,
193 "identity": addon._file_identity(fr)}})
194 measure, _ = _measure_by_body({"orig": (0.33, 1.0), "fixed": (0.0, 1.0)})
195 monkeypatch.setattr(addon, "_subsync_measure", measure)
196 addon._sync_subtitles(lib)
197 rec = _records()[os.path.abspath(fr)]
198 assert rec["ref"] == addon.SUBSYNC_REF_VERSION and rec["status"] == "corrected"
199
200
201def test_an_untouched_other_language_is_still_left_alone(lib, monkeypatch):
202 _write(_sidecar(lib, "fr"), "orig")
203 monkeypatch.setattr(addon, "_subsync_measure",
204 lambda *a: pytest.fail("measured a language out of scope"))
205 assert addon._sync_subtitles(lib)["tracks"] == 0
206
207
208def test_a_track_the_user_kept_is_still_theirs(lib, monkeypatch):
209 en = _write(_sidecar(lib, "en"), "restored")
210 _record({os.path.abspath(en): {"status": "kept", "offset": 22.18, "scale": 1.0,
211 "identity": addon._file_identity(en)}})
212 monkeypatch.setattr(addon, "_subsync_measure",
213 lambda *a: pytest.fail("re-measured a kept track"))
214 assert addon._sync_subtitles(lib)["tracks"] == 0
215
216
217def test_a_current_result_is_not_measured_again(lib, monkeypatch):
218 en = _write(_sidecar(lib, "en"), "orig")
219 measure, seen = _measure_by_body({})
220 monkeypatch.setattr(addon, "_subsync_measure", measure)
221 addon._sync_subtitles(lib)
222 addon._sync_subtitles(lib)
223 assert len(seen) == 1