| … | … | @@ -20,3 +20,5 @@ library_verified.json |
| 20 | 20 | ingest_queue.json |
| 21 | 21 | ingest_history.jsonl |
| 22 | 22 | jellyfin.json |
| 23 | subtitle_sync.json | |
| 24 | subs-original/ |
| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | "version": "0.26.1", | |
| 622 | "version": "0.27.0", | |
| 623 | 623 | "name": "Tabertron VsEmbed", |
| 624 | 624 | "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub", |
| 625 | 625 | "types": ["movie", "series"], |
| … | … | @@ -2179,6 +2179,234 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None: |
| 2179 | 2179 | os.replace(tmp, out_path) |
| 2180 | 2180 | |
| 2181 | 2181 | |
| 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. | |
| 2196 | SUBSYNC_BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), | |
| 2197 | ".venv", "bin", "ffsubsync") | |
| 2198 | SUBSYNC_TOLERANCE = 0.3 # seconds; the families measured differ by >= 2 | |
| 2199 | SUBSYNC_TIMEOUT = 15 * 60 # speech extraction of a 2h19 film took 70 s | |
| 2200 | SUBS_ORIGINAL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), | |
| 2201 | "subs-original") | |
| 2202 | SUBTITLE_SYNC_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), | |
| 2203 | "subtitle_sync.json") | |
| 2204 | _subsync_guard = threading.Lock() | |
| 2205 | ||
| 2206 | ||
| 2207 | def _subsync_available() -> bool: | |
| 2208 | return os.access(SUBSYNC_BIN, os.X_OK) | |
| 2209 | ||
| 2210 | ||
| 2211 | def _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 | ||
| 2224 | def _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 | ||
| 2248 | def _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 | ||
| 2262 | def _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 | ||
| 2267 | def _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 | ||
| 2279 | def _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 | ||
| 2287 | def _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 | ||
| 2302 | def _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 | ||
| 2383 | def _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 | ||
| 2182 | 2410 | def _fetch_subtitles_for(dest: str, meta: dict, season: int, episode: int) -> None: |
| 2183 | 2411 | """Put whatever subtitles exist beside a published copy. Never raises. |
| 2184 | 2412 | |
| … | … | @@ -2192,6 +2420,14 @@ def _fetch_subtitles_for(dest: str, meta: dict, season: int, episode: int) -> No |
| 2192 | 2420 | except Exception as exc: |
| 2193 | 2421 | log.warning("subtitles: skipped for %s (%s)", |
| 2194 | 2422 | 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) | |
| 2195 | 2431 | |
| 2196 | 2432 | |
| 2197 | 2433 | def _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, |
| 2345 | 2581 | _set_job_state(key, "running", |
| 2346 | 2582 | args=(kind, ident, season, episode, height)) |
| 2347 | 2583 | _job_ctx.cancel = cancel |
| 2584 | _job_ctx.key = key | |
| 2348 | 2585 | meta = None |
| 2349 | 2586 | acquired = False |
| 2350 | 2587 | try: |
| … | … | @@ -2386,6 +2623,7 @@ def _run_job(kind: str, ident: str, season: int, episode: int, |
| 2386 | 2623 | state = f"failed: {exc}" |
| 2387 | 2624 | finally: |
| 2388 | 2625 | _job_ctx.cancel = None |
| 2626 | _job_ctx.key = None | |
| 2389 | 2627 | info = _job_info.get(key) or {} |
| 2390 | 2628 | _record_attempt(key, info, state, started) |
| 2391 | 2629 | with _mp4_jobs_guard: |
| … | … | @@ -4559,6 +4797,7 @@ def _quality_html(path: str, meta: Optional[dict]) -> str: |
| 4559 | 4797 | rows.append(("Length", length)) |
| 4560 | 4798 | rows.append(("Size", _human_bytes(os.path.getsize(path)))) |
| 4561 | 4799 | rows.append(("Source", _source_of(path) or "not recorded")) |
| 4800 | rows.append(("Subtitles", _subtitle_summary(path))) | |
| 4562 | 4801 | warn = "" |
| 4563 | 4802 | if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO: |
| 4564 | 4803 | warn = ("<p class=warn>This copy plays without sound here: its first " |
| … | … | @@ -22,6 +22,12 @@ def no_production_history(tmp_path, monkeypatch): |
| 22 | 22 | read one sets it explicitly (and lands in its own tmp_path anyway).""" |
| 23 | 23 | monkeypatch.setattr(addon, "INGEST_HISTORY_FILE", |
| 24 | 24 | 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) | |
| 25 | 31 | yield |
| 26 | 32 | |
| 27 | 33 |
| … | … | @@ -1,2 +1,3 @@ |
| 1 | 1 | flask>=3.0 |
| 2 | 2 | requests>=2.31 |
| 3 | ffsubsync>=0.5.1 |
| … | … | @@ -0,0 +1,48 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Measure and repair the subtitles of copies this addon already fetched. | |
| 3 | ||
| 4 | New ingests sync their sidecars as they land (_fetch_subtitles_for). This is | |
| 5 | the 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 | |
| 7 | re-run -- or an interrupted run resumed -- costs one stat per track. | |
| 8 | ||
| 9 | Scope is the ingest queue (_fetched_dests), the same scope as Delete: copies | |
| 10 | this addon fetched. | |
| 11 | ||
| 12 | Usage: | |
| 13 | nice -n 15 .venv/bin/python3 sync_subtitles.py [--limit N] [PATH ...] | |
| 14 | """ | |
| 15 | ||
| 16 | import argparse | |
| 17 | import os | |
| 18 | import sys | |
| 19 | import time | |
| 20 | ||
| 21 | import addon | |
| 22 | ||
| 23 | ||
| 24 | def 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 | ||
| 47 | if __name__ == "__main__": | |
| 48 | sys.exit(main()) |
| … | … | @@ -0,0 +1,224 @@ |
| 1 | """Subtitle sync: measure each sidecar against the copy's own audio (labs#13). | |
| 2 | ||
| 3 | Measured 2026-10-04 on No Other Choice with ffsubsync, against speech | |
| 4 | extracted 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 | |
| 9 | Corrected tracks re-measured at 0.000-0.010 s / 1.000, cue count unchanged. | |
| 10 | ||
| 11 | So, 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 | |
| 17 | Results are recorded per file identity, so a re-run skips what it has done. | |
| 18 | ||
| 19 | The two seams -- _subsync_reference (speech, once per copy) and | |
| 20 | _subsync_measure (one track) -- are what the tests replace; no test runs | |
| 21 | ffsubsync. | |
| 22 | ||
| 23 | Run: .venv/bin/python3 -m pytest test_subtitle_sync.py -q | |
| 24 | """ | |
| 25 | ||
| 26 | import json | |
| 27 | import os | |
| 28 | ||
| 29 | import pytest | |
| 30 | ||
| 31 | import addon | |
| 32 | ||
| 33 | ||
| 34 | @pytest.fixture | |
| 35 | def 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 | ||
| 50 | def _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 | ||
| 57 | def _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 | ||
| 72 | def 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 | ||
| 83 | def 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 | ||
| 98 | def 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 | ||
| 111 | def 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 | ||
| 124 | def 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 | ||
| 144 | def 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 | ||
| 157 | def 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 | ||
| 170 | def 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 | ||
| 177 | def 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 | ||
| 187 | def 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 | ||
| 197 | def 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 | ||
| 206 | def 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 | ||
| 218 | def 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 |
| … | … | @@ -24,3 +24,9 @@ def test_history_is_never_the_production_file(): |
| 24 | 24 | |
| 25 | 25 | def test_jellyfin_config_is_never_the_production_file(): |
| 26 | 26 | assert os.path.dirname(os.path.abspath(addon.JELLYFIN_CONFIG)) != HERE |
| 27 | ||
| 28 | ||
| 29 | def 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 |