OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 7169f4d

Subtitle sync: measure every track against the copy's own audio (0.27.0)
labs#13. wyzie tracks are cut for other releases. Measured with ffsubsync on No Other Choice against speech extracted once from the film: none of 32 tracks matched -- 23 at -8.9 s (another cut), 7 incl. English at -2.2 s and scale 1.001 (24 vs 23.976 fps, drift reaching ~8 s: the reported "keeps going out of sync"), two at -18/-20 s. Corrections re-measured at 0.000-0.010 s / 1.000 with every cue kept. After each ingest's subtitles land (phase "syncing subtitles"), per track: in sync (|offset| <= 0.3 s, scale 1.000) -> untouched fixable (correction re-measures clean) -> original to subs-original/ (outside the library, never deleted), corrected copy takes its place unfixable (correction does not hold) -> original to the store, withdrawn unmeasurable -> left alone Speech is extracted once per copy (from a symlink in a temp dir, so the npz never lands in the library). Results recorded per file identity in subtitle_sync.json (atomic), so re-runs skip; Jellyfin told "Modified" when anything changed; Watch shows a one-line summary. sync_subtitles.py is the one-off for copies fetched before this. conftest: sync record + store redirected and ffsubsync reported absent by default -- stortron's venv has the real binary. requirements: ffsubsync. 13 tests. Suite 482. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7169f4d9ec813696766578dd302c3476673c8b1d
by plankatron <fleeky@gmail.com> · 2026-10-05T01:35:46.000Z

Changed files (7) +527 -1

.gitignore +2 -0
……@@ -20,3 +20,5 @@ library_verified.json
2020ingest_queue.json
2121ingest_history.jsonl
2222jellyfin.json
23subtitle_sync.json
24subs-original/
addon.py +240 -1
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.26.1",
622 "version": "0.27.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -2179,6 +2179,234 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None:
21792179 os.replace(tmp, out_path)
21802180
21812181
2182# ------------------------------- subtitle sync ------------------------------
2183# wyzie tracks are not cut for OUR copy. Measured 2026-10-04 with ffsubsync on
2184# No Other Choice, against speech extracted once from the film: none of 32
2185# tracks matched. 23 sat at -8.9 s (another cut/intro), 7 including English at
2186# -2.2 s with scale 1.001 (24 vs 23.976 fps: drift reaching ~8 s by the end --
2187# the "keeps going out of sync" report), two at -18/-20 s. Corrected tracks
2188# re-measured at 0.000-0.010 s / 1.000 with every cue kept.
2189#
2190# Per track: in sync -> untouched. Fixable (the correction re-measures clean)
2191# -> original moved to SUBS_ORIGINAL_DIR, outside the library, never deleted;
2192# corrected copy takes its place. Unfixable (the correction does not hold: a
2193# different edit, which offset+scale cannot repair) -> original moved to the
2194# store and the library stops offering it. A measurement that cannot run
2195# leaves the track alone: a check that fails must not cost a subtitle.
2196SUBSYNC_BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)),
2197 ".venv", "bin", "ffsubsync")
2198SUBSYNC_TOLERANCE = 0.3 # seconds; the families measured differ by >= 2
2199SUBSYNC_TIMEOUT = 15 * 60 # speech extraction of a 2h19 film took 70 s
2200SUBS_ORIGINAL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
2201 "subs-original")
2202SUBTITLE_SYNC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
2203 "subtitle_sync.json")
2204_subsync_guard = threading.Lock()
2205
2206
2207def _subsync_available() -> bool:
2208 return os.access(SUBSYNC_BIN, os.X_OK)
2209
2210
2211def _parse_subsync(out: str) -> Optional[dict]:
2212 """offset/scale/score from ffsubsync's own log lines, or None."""
2213 nums = {}
2214 for name, pattern in (("score", r"score: ([-\d.]+)"),
2215 ("offset", r"offset seconds: ([-\d.]+)"),
2216 ("scale", r"framerate scale factor: ([-\d.]+)")):
2217 m = re.search(pattern, out or "")
2218 if not m:
2219 return None
2220 nums[name] = float(m.group(1))
2221 return nums
2222
2223
2224def _subsync_reference(dest: str, workdir: str) -> Optional[str]:
2225 """Extract the copy's speech ONCE into workdir; the npz is the reference
2226 every track is then measured against (~6 s each instead of ~70 s).
2227
2228 Run from a symlink inside workdir, because --serialize-speech writes the
2229 npz beside its input -- which would otherwise be the library folder.
2230 """
2231 sidecars = _sidecars_of(dest)
2232 if not sidecars:
2233 return None
2234 link = os.path.join(workdir, "ref" + os.path.splitext(dest)[1])
2235 try:
2236 os.symlink(dest, link)
2237 subprocess.run([SUBSYNC_BIN, link, "-i", sidecars[0], "-o", os.devnull,
2238 "--serialize-speech"], cwd=workdir,
2239 capture_output=True, text=True, timeout=SUBSYNC_TIMEOUT)
2240 except Exception as exc:
2241 log.warning("subsync: speech extraction failed for %s (%s)",
2242 os.path.basename(dest), exc)
2243 return None
2244 npz = os.path.join(workdir, "ref.npz")
2245 return npz if os.path.exists(npz) else None
2246
2247
2248def _subsync_measure(ref: str, srt_in: str, srt_out: Optional[str]) -> Optional[dict]:
2249 """Measure one track against the reference; write the correction to
2250 srt_out when given. None if ffsubsync could not produce a measurement."""
2251 try:
2252 out = subprocess.run([SUBSYNC_BIN, ref, "-i", srt_in,
2253 "-o", srt_out or os.devnull],
2254 capture_output=True, text=True,
2255 timeout=SUBSYNC_TIMEOUT)
2256 except Exception as exc:
2257 log.warning("subsync: %s not measured (%s)", os.path.basename(srt_in), exc)
2258 return None
2259 return _parse_subsync(out.stdout + out.stderr)
2260
2261
2262def _in_tolerance(m: Optional[dict]) -> bool:
2263 return bool(m) and abs(m["offset"]) <= SUBSYNC_TOLERANCE \
2264 and round(m["scale"], 3) == 1.0
2265
2266
2267def _load_sync() -> dict:
2268 try:
2269 with open(SUBTITLE_SYNC_FILE) as f:
2270 data = json.load(f)
2271 return data if isinstance(data, dict) else {}
2272 except FileNotFoundError:
2273 return {}
2274 except Exception as exc:
2275 log.warning("subsync: record unreadable (%s)", exc)
2276 raise
2277
2278
2279def _file_identity(path: str) -> Optional[list]:
2280 try:
2281 st = os.stat(path)
2282 return [st.st_size, int(st.st_mtime)]
2283 except OSError:
2284 return None
2285
2286
2287def _keep_original(path: str) -> str:
2288 """Copy a sidecar into the store, mirroring its library path. Never
2289 overwrites an earlier original: a later one gets a numeric suffix."""
2290 rel = os.path.relpath(path, LIBRARY_ROOT)
2291 target = os.path.join(SUBS_ORIGINAL_DIR, rel)
2292 os.makedirs(os.path.dirname(target), exist_ok=True)
2293 n, candidate = 1, target
2294 while os.path.exists(candidate):
2295 n += 1
2296 candidate = f"{target}.{n}"
2297 # copyfile, not copy2: copystat fails on the exFAT library (2026-08-31).
2298 shutil.copyfile(path, candidate)
2299 return candidate
2300
2301
2302def _sync_subtitles(dest: str) -> dict:
2303 """Measure and repair every not-yet-checked sidecar beside one copy.
2304
2305 Idempotent: a track whose file identity matches its record is skipped, so
2306 the ingest path and the backfill share this and re-runs cost nothing.
2307 Never raises.
2308 """
2309 result = {"tracks": 0, "in_sync": 0, "corrected": 0, "unfixable": 0,
2310 "unmeasured": 0}
2311 if not _subsync_available():
2312 return result
2313 try:
2314 records = _load_sync()
2315 except Exception:
2316 return result # unreadable record: touch nothing
2317 todo = [p for p in _sidecars_of(dest)
2318 if (records.get(os.path.abspath(p)) or {}).get("identity")
2319 != _file_identity(p)]
2320 result["tracks"] = len(todo)
2321 if not todo:
2322 return result
2323 changed = False
2324 fresh: dict[str, dict] = {}
2325 with tempfile.TemporaryDirectory(prefix="subsync-") as work:
2326 ref = _subsync_reference(dest, work)
2327 if not ref:
2328 result["unmeasured"] = len(todo)
2329 return result
2330 for path in todo:
2331 if _cancel_requested():
2332 break
2333 corrected = os.path.join(work, os.path.basename(path) + ".synced")
2334 m = _subsync_measure(ref, path, corrected)
2335 if not m:
2336 result["unmeasured"] += 1
2337 continue
2338 entry = {"offset": m["offset"], "scale": m["scale"], "ts": time.time()}
2339 try:
2340 if _in_tolerance(m):
2341 entry["status"] = "in_sync"
2342 else:
2343 check = (_subsync_measure(ref, corrected, None)
2344 if os.path.exists(corrected) else None)
2345 entry["original"] = _keep_original(path)
2346 if _in_tolerance(check):
2347 # Across filesystems (/tmp -> the USB library), so a
2348 # copy to .part and a rename, not os.replace.
2349 part = _part_path(path)
2350 shutil.copyfile(corrected, part)
2351 os.replace(part, path)
2352 entry["status"] = "corrected"
2353 else:
2354 os.remove(path) # the original is in the store
2355 entry["status"] = "unfixable"
2356 changed = True
2357 except Exception as exc:
2358 log.warning("subsync: %s left as it was (%s)",
2359 os.path.basename(path), exc)
2360 result["unmeasured"] += 1
2361 continue
2362 entry["identity"] = _file_identity(path)
2363 fresh[os.path.abspath(path)] = entry
2364 result[entry["status"]] += 1
2365 log.info("subsync: %s %s (offset %.2f s, scale %.3f)",
2366 os.path.basename(path), entry["status"],
2367 m["offset"], m["scale"])
2368 try:
2369 with _subsync_guard:
2370 records = _load_sync()
2371 records.update(fresh)
2372 tmp = SUBTITLE_SYNC_FILE + ".tmp"
2373 with open(tmp, "w") as f:
2374 json.dump(records, f)
2375 os.replace(tmp, SUBTITLE_SYNC_FILE)
2376 except Exception as exc:
2377 log.warning("subsync: record save failed (%s)", exc)
2378 if changed:
2379 _notify_jellyfin(dest, "Modified")
2380 return result
2381
2382
2383def _subtitle_summary(dest: str) -> str:
2384 """One line for the watch page: what the sync found for this copy."""
2385 present = _sidecars_of(dest)
2386 if not present:
2387 return "none"
2388 try:
2389 records = _load_sync()
2390 except Exception:
2391 records = {}
2392 stem = os.path.splitext(os.path.abspath(dest))[0] + "."
2393 counts = {"corrected": 0, "in_sync": 0, "unchecked": 0}
2394 for p in present:
2395 rec = records.get(os.path.abspath(p)) or {}
2396 status = rec.get("status") if rec.get("identity") == _file_identity(p) else None
2397 counts[status if status in counts else "unchecked"] += 1
2398 removed = sum(1 for k, v in records.items()
2399 if k.startswith(stem) and v.get("status") == "unfixable")
2400 parts = [f"{len(present)} tracks"]
2401 for label, n in (("corrected", counts["corrected"]),
2402 ("in sync", counts["in_sync"]),
2403 ("not yet checked", counts["unchecked"]),
2404 ("removed (out of sync)", removed)):
2405 if n:
2406 parts.append(f"{n} {label}")
2407 return " · ".join(parts)
2408
2409
21822410def _fetch_subtitles_for(dest: str, meta: dict, season: int, episode: int) -> None:
21832411 """Put whatever subtitles exist beside a published copy. Never raises.
21842412
……@@ -2192,6 +2420,14 @@ def _fetch_subtitles_for(dest: str, meta: dict, season: int, episode: int) -> No
21922420 except Exception as exc:
21932421 log.warning("subtitles: skipped for %s (%s)",
21942422 os.path.basename(dest), exc)
2423 # Measured against THIS copy's audio, because wyzie's tracks are cut for
2424 # other releases (labs#13). The copy is already published; this only ever
2425 # replaces or withdraws sidecars, and never raises.
2426 _job_update(getattr(_job_ctx, "key", None) or "", phase="syncing subtitles")
2427 try:
2428 _sync_subtitles(dest)
2429 except Exception as exc:
2430 log.warning("subsync: skipped for %s (%s)", os.path.basename(dest), exc)
21952431
21962432
21972433def _run_ingest(kind: str, ident: str, season: int, episode: int,
……@@ -2345,6 +2581,7 @@ def _run_job(kind: str, ident: str, season: int, episode: int,
23452581 _set_job_state(key, "running",
23462582 args=(kind, ident, season, episode, height))
23472583 _job_ctx.cancel = cancel
2584 _job_ctx.key = key
23482585 meta = None
23492586 acquired = False
23502587 try:
……@@ -2386,6 +2623,7 @@ def _run_job(kind: str, ident: str, season: int, episode: int,
23862623 state = f"failed: {exc}"
23872624 finally:
23882625 _job_ctx.cancel = None
2626 _job_ctx.key = None
23892627 info = _job_info.get(key) or {}
23902628 _record_attempt(key, info, state, started)
23912629 with _mp4_jobs_guard:
……@@ -4559,6 +4797,7 @@ def _quality_html(path: str, meta: Optional[dict]) -> str:
45594797 rows.append(("Length", length))
45604798 rows.append(("Size", _human_bytes(os.path.getsize(path))))
45614799 rows.append(("Source", _source_of(path) or "not recorded"))
4800 rows.append(("Subtitles", _subtitle_summary(path)))
45624801 warn = ""
45634802 if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO:
45644803 warn = ("<p class=warn>This copy plays without sound here: its first "
conftest.py +6 -0
……@@ -22,6 +22,12 @@ def no_production_history(tmp_path, monkeypatch):
2222 read one sets it explicitly (and lands in its own tmp_path anyway)."""
2323 monkeypatch.setattr(addon, "INGEST_HISTORY_FILE",
2424 str(tmp_path / "ingest_history.jsonl"))
25 # Subtitle sync: the record and the originals store sit beside addon.py,
26 # and stortron's venv HAS ffsubsync -- so by default a test reaching
27 # _fetch_subtitles_for would run it for real against production paths.
28 monkeypatch.setattr(addon, "SUBTITLE_SYNC_FILE", str(tmp_path / "subtitle_sync.json"))
29 monkeypatch.setattr(addon, "SUBS_ORIGINAL_DIR", str(tmp_path / "subs-original"))
30 monkeypatch.setattr(addon, "_subsync_available", lambda: False)
2531 yield
2632
2733
requirements.txt +1 -0
……@@ -1,2 +1,3 @@
11flask>=3.0
22requests>=2.31
3ffsubsync>=0.5.1
sync_subtitles.py +48 -0
……@@ -0,0 +1,48 @@
1#!/usr/bin/env python3
2"""Measure and repair the subtitles of copies this addon already fetched.
3
4New ingests sync their sidecars as they land (_fetch_subtitles_for). This is
5the one-off for everything fetched before that existed. It calls the same
6_sync_subtitles the ingest uses, which skips any track already recorded, so a
7re-run -- or an interrupted run resumed -- costs one stat per track.
8
9Scope is the ingest queue (_fetched_dests), the same scope as Delete: copies
10this addon fetched.
11
12Usage:
13 nice -n 15 .venv/bin/python3 sync_subtitles.py [--limit N] [PATH ...]
14"""
15
16import argparse
17import os
18import sys
19import time
20
21import addon
22
23
24def main() -> int:
25 ap = argparse.ArgumentParser()
26 ap.add_argument("paths", nargs="*", help="specific library copies (default: all fetched)")
27 ap.add_argument("--limit", type=int, default=0)
28 args = ap.parse_args()
29 if not addon._subsync_available():
30 print(f"ffsubsync not found at {addon.SUBSYNC_BIN}", file=sys.stderr)
31 return 1
32 copies = args.paths or sorted(p for p in addon._fetched_dests() if os.path.exists(p))
33 if args.limit:
34 copies = copies[:args.limit]
35 total = {"tracks": 0, "in_sync": 0, "corrected": 0, "unfixable": 0, "unmeasured": 0}
36 for i, dest in enumerate(copies, 1):
37 t0 = time.time()
38 r = addon._sync_subtitles(dest)
39 for k in total:
40 total[k] += r[k]
41 print(f"[{i}/{len(copies)}] {os.path.basename(dest)}: {r} "
42 f"({time.time() - t0:.0f}s)", flush=True)
43 print("total:", total, flush=True)
44 return 0
45
46
47if __name__ == "__main__":
48 sys.exit(main())
test_subtitle_sync.py +224 -0
……@@ -0,0 +1,224 @@
1"""Subtitle sync: measure each sidecar against the copy's own audio (labs#13).
2
3Measured 2026-10-04 on No Other Choice with ffsubsync, against speech
4extracted once from the film: NOT ONE of 32 wyzie tracks matched the copy.
5 - 23 tracks: offset -8.9 s, scale 1.000 (another cut/intro)
6 - 7 tracks incl. en: offset -2.2 s, scale 1.001 (24 vs 23.976 fps -- drift
7 that grows to ~8 s over the film: the "keeps going out of sync" report)
8 - ar -20 s, de -18.5 s
9Corrected tracks re-measured at 0.000-0.010 s / 1.000, cue count unchanged.
10
11So, per sidecar:
12 in sync |offset| <= SUBSYNC_TOLERANCE and scale 1.000 -> untouched
13 fixable corrected copy re-measures in tolerance -> original to the store
14 (outside the library, never deleted), corrected takes its place
15 unfixable the correction does not re-measure clean (a different edit) ->
16 original moved to the store; the library no longer offers it
17Results are recorded per file identity, so a re-run skips what it has done.
18
19The two seams -- _subsync_reference (speech, once per copy) and
20_subsync_measure (one track) -- are what the tests replace; no test runs
21ffsubsync.
22
23Run: .venv/bin/python3 -m pytest test_subtitle_sync.py -q
24"""
25
26import json
27import os
28
29import pytest
30
31import addon
32
33
34@pytest.fixture
35def lib(tmp_path, monkeypatch):
36 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib"))
37 monkeypatch.setattr(addon, "SUBS_ORIGINAL_DIR", str(tmp_path / "subs-original"))
38 monkeypatch.setattr(addon, "SUBTITLE_SYNC_FILE", str(tmp_path / "sync.json"))
39 monkeypatch.setattr(addon, "_subsync_available", lambda: True)
40 monkeypatch.setattr(addon, "_subsync_reference",
41 lambda dest, workdir: os.path.join(workdir, "ref.npz"))
42 notified = []
43 monkeypatch.setattr(addon, "_notify_jellyfin", lambda p, t: notified.append((p, t)))
44 dest = tmp_path / "lib" / "Movies" / "Film (2025) [tmdbid-1]" / "Film (2025).mp4"
45 dest.parent.mkdir(parents=True)
46 dest.write_bytes(b"video")
47 return str(dest), notified
48
49
50def _srt(dest, lang, body="1\n00:00:01,000 --> 00:00:02,000\nhi\n"):
51 p = os.path.splitext(dest)[0] + f".{lang}.srt"
52 with open(p, "w") as f:
53 f.write(body)
54 return p
55
56
57def _measure(table):
58 """Fake _subsync_measure: (offset, scale) per input file NAME; writes the
59 'corrected' output so replacement can be observed."""
60 calls = []
61
62 def measure(ref, srt_in, srt_out):
63 calls.append(os.path.basename(srt_in))
64 offset, scale = table[os.path.basename(srt_in)]
65 if srt_out:
66 with open(srt_out, "w") as f:
67 f.write(f"corrected {os.path.basename(srt_in)}")
68 return {"offset": offset, "scale": scale, "score": 1.0}
69 return measure, calls
70
71
72def test_an_in_sync_track_is_left_untouched(lib, monkeypatch):
73 dest, _ = lib
74 en = _srt(dest, "en")
75 measure, _ = _measure({"Film (2025).en.srt": (0.1, 1.000)})
76 monkeypatch.setattr(addon, "_subsync_measure", measure)
77 result = addon._sync_subtitles(dest)
78 assert result["in_sync"] == 1
79 assert open(en).read().startswith("1\n")
80 assert not os.path.exists(addon.SUBS_ORIGINAL_DIR) or not os.listdir(addon.SUBS_ORIGINAL_DIR)
81
82
83def test_a_drifting_track_is_replaced_and_the_original_kept_outside_the_library(lib, monkeypatch):
84 dest, _ = lib
85 en = _srt(dest, "en")
86 measure, _ = _measure({"Film (2025).en.srt": (-2.25, 1.001),
87 "Film (2025).en.srt.synced": (0.01, 1.000)})
88 monkeypatch.setattr(addon, "_subsync_measure", measure)
89 result = addon._sync_subtitles(dest)
90 assert result["corrected"] == 1
91 assert open(en).read() == "corrected Film (2025).en.srt"
92 kept = os.path.join(addon.SUBS_ORIGINAL_DIR,
93 os.path.relpath(en, addon.LIBRARY_ROOT))
94 assert open(kept).read().startswith("1\n")
95 assert not kept.startswith(addon.LIBRARY_ROOT + os.sep)
96
97
98def test_a_track_whose_correction_does_not_hold_is_removed_from_the_library(lib, monkeypatch):
99 dest, _ = lib
100 de = _srt(dest, "de")
101 measure, _ = _measure({"Film (2025).de.srt": (-18.5, 1.000),
102 "Film (2025).de.srt.synced": (4.0, 1.000)})
103 monkeypatch.setattr(addon, "_subsync_measure", measure)
104 result = addon._sync_subtitles(dest)
105 assert result["unfixable"] == 1
106 assert not os.path.exists(de)
107 kept = os.path.join(addon.SUBS_ORIGINAL_DIR, os.path.relpath(de, addon.LIBRARY_ROOT))
108 assert os.path.exists(kept)
109
110
111def test_a_measurement_failure_leaves_the_track_alone(lib, monkeypatch):
112 """A check that cannot run must not cost the user a subtitle."""
113 dest, _ = lib
114 en = _srt(dest, "en")
115
116 def broken(ref, srt_in, srt_out):
117 return None
118 monkeypatch.setattr(addon, "_subsync_measure", broken)
119 result = addon._sync_subtitles(dest)
120 assert result["unmeasured"] == 1
121 assert os.path.exists(en) and open(en).read().startswith("1\n")
122
123
124def test_results_are_recorded_and_a_rerun_skips_them(lib, monkeypatch):
125 dest, _ = lib
126 _srt(dest, "en")
127 _srt(dest, "fr")
128 measure, calls = _measure({"Film (2025).en.srt": (0.0, 1.000),
129 "Film (2025).fr.srt": (-8.93, 1.000),
130 "Film (2025).fr.srt.synced": (0.0, 1.000)})
131 monkeypatch.setattr(addon, "_subsync_measure", measure)
132 addon._sync_subtitles(dest)
133 with open(addon.SUBTITLE_SYNC_FILE) as f:
134 rec = json.load(f)
135 en = os.path.abspath(os.path.splitext(dest)[0] + ".en.srt")
136 fr = os.path.abspath(os.path.splitext(dest)[0] + ".fr.srt")
137 assert rec[en]["status"] == "in_sync"
138 assert rec[fr]["status"] == "corrected" and rec[fr]["offset"] == -8.93
139 calls.clear()
140 addon._sync_subtitles(dest)
141 assert calls == []
142
143
144def test_a_new_track_beside_synced_ones_is_measured(lib, monkeypatch):
145 dest, _ = lib
146 _srt(dest, "en")
147 measure, calls = _measure({"Film (2025).en.srt": (0.0, 1.000),
148 "Film (2025).it.srt": (0.0, 1.000)})
149 monkeypatch.setattr(addon, "_subsync_measure", measure)
150 addon._sync_subtitles(dest)
151 _srt(dest, "it")
152 calls.clear()
153 addon._sync_subtitles(dest)
154 assert calls == ["Film (2025).it.srt"]
155
156
157def test_speech_is_extracted_once_per_copy(lib, monkeypatch):
158 dest, _ = lib
159 for lang in ("en", "fr", "it"):
160 _srt(dest, lang)
161 refs = []
162 monkeypatch.setattr(addon, "_subsync_reference",
163 lambda d, w: refs.append(d) or os.path.join(w, "ref.npz"))
164 measure, _ = _measure({f"Film (2025).{x}.srt": (0.0, 1.000) for x in ("en", "fr", "it")})
165 monkeypatch.setattr(addon, "_subsync_measure", measure)
166 addon._sync_subtitles(dest)
167 assert refs == [dest]
168
169
170def test_nothing_to_do_extracts_nothing(lib, monkeypatch):
171 dest, _ = lib
172 monkeypatch.setattr(addon, "_subsync_reference",
173 lambda d, w: pytest.fail("extracted speech for no tracks"))
174 assert addon._sync_subtitles(dest)["tracks"] == 0
175
176
177def test_without_ffsubsync_nothing_changes(lib, monkeypatch):
178 dest, _ = lib
179 en = _srt(dest, "en")
180 monkeypatch.setattr(addon, "_subsync_available", lambda: False)
181 monkeypatch.setattr(addon, "_subsync_measure",
182 lambda *a: pytest.fail("measured without ffsubsync"))
183 assert addon._sync_subtitles(dest)["tracks"] == 0
184 assert os.path.exists(en)
185
186
187def test_jellyfin_is_told_once_when_tracks_changed(lib, monkeypatch):
188 dest, notified = lib
189 _srt(dest, "fr")
190 measure, _ = _measure({"Film (2025).fr.srt": (-8.9, 1.000),
191 "Film (2025).fr.srt.synced": (0.0, 1.000)})
192 monkeypatch.setattr(addon, "_subsync_measure", measure)
193 addon._sync_subtitles(dest)
194 assert notified == [(dest, "Modified")]
195
196
197def test_jellyfin_is_not_told_when_nothing_changed(lib, monkeypatch):
198 dest, notified = lib
199 _srt(dest, "en")
200 measure, _ = _measure({"Film (2025).en.srt": (0.0, 1.000)})
201 monkeypatch.setattr(addon, "_subsync_measure", measure)
202 addon._sync_subtitles(dest)
203 assert notified == []
204
205
206def test_the_watch_panel_reports_the_sync(lib, monkeypatch):
207 dest, _ = lib
208 _srt(dest, "en")
209 _srt(dest, "fr")
210 measure, _ = _measure({"Film (2025).en.srt": (0.0, 1.000),
211 "Film (2025).fr.srt": (-8.9, 1.000),
212 "Film (2025).fr.srt.synced": (0.0, 1.000)})
213 monkeypatch.setattr(addon, "_subsync_measure", measure)
214 addon._sync_subtitles(dest)
215 assert addon._subtitle_summary(dest) == "2 tracks · 1 corrected · 1 in sync"
216
217
218def test_parse_reads_ffsubsyncs_own_output():
219 out = ("[21:26:25] INFO score: 343738.036 ffsubsync.py:255\n"
220 " INFO offset seconds: -2.250 ffsubsync.py:256\n"
221 " INFO framerate scale factor: 1.001 ffsubsync.py:257\n")
222 assert addon._parse_subsync(out) == {"offset": -2.25, "scale": 1.001,
223 "score": 343738.036}
224 assert addon._parse_subsync("garbage") is None
test_suite_isolation.py +6 -0
……@@ -24,3 +24,9 @@ def test_history_is_never_the_production_file():
2424
2525def test_jellyfin_config_is_never_the_production_file():
2626 assert os.path.dirname(os.path.abspath(addon.JELLYFIN_CONFIG)) != HERE
27
28
29def test_subtitle_sync_state_is_never_production():
30 assert os.path.dirname(os.path.abspath(addon.SUBTITLE_SYNC_FILE)) != HERE
31 assert os.path.dirname(os.path.abspath(addon.SUBS_ORIGINAL_DIR)) != HERE
32 assert addon._subsync_available() is False