OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 08b62af

Subtitles synced to a live stream (WIP: not yet exercised on real streams)
/subs/<kind>/<ident>.json?src=vidcore|hdhub|mp4&h=N: with no copy behind the stream, six 150 s audio windows of the stream being played are VAD'd and window_align places each English track (wyzie, else OpenSubtitles). Only placed tracks are offered. vidcore windows are init + segments concatenated (ffmpeg cannot seek its muxed fMP4); HdHub windows are ranged seeks on the English audio. Results cached in live-subs/ (conftest redirect + isolation test). Without src nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
08b62af19344341989fe2282e85edc5496903b8d
by plankatron <fleeky@gmail.com> · 2026-10-19T01:02:57.000Z

Changed files (4) +650 -2

addon.py +368 -2
……@@ -4,6 +4,7 @@ with a vsembed.ru/CloudStream Pro fallback (currently broken — Cloudflare Turn
44The fmovies path runs node + Playwright via subprocess to handle the JS-driven extraction.
55"""
66
7import bisect
78import calendar
89import contextlib
910import html
……@@ -3200,6 +3201,9 @@ def _subs_by_id(kind: str, ident: str) -> dict:
32003201
32013202@app.route("/subs/<kind>/<path:ident>.json")
32023203def subs_list(kind: str, ident: str):
3204 live = _live_request(kind, ident)
3205 if live:
3206 return jsonify(_live_answer(kind, ident, *live))
32033207 out = []
32043208 for sid, p in sorted(_subs_by_id(kind, ident).items()):
32053209 out.append({"id": sid, "lang": re.sub(r"\.\d+$", "", sid),
……@@ -3211,13 +3215,375 @@ def subs_list(kind: str, ident: str):
32113215
32123216@app.route("/subs/<kind>/<path:ident>/<sid>.srt")
32133217def subs_file(kind: str, ident: str, sid: str):
3214 # Looked up among the copy's own sidecars, never joined onto a path.
3215 path = _subs_by_id(kind, ident).get(sid)
3218 # Looked up among the copy's own sidecars (or the live sync's own list),
3219 # never joined onto a path.
3220 live = _live_request(kind, ident)
3221 path = (_live_file(kind, ident, sid, *live) if live
3222 else _subs_by_id(kind, ident).get(sid))
32163223 if not path:
32173224 abort(404)
32183225 return send_file(path, mimetype="application/x-subrip")
32193226
32203227
3228# ---------------------------- live streams: subtitles synced to the stream ---
3229# A stream played from vidcore or HdHub has no copy to measure against, and
3230# taking all its audio costs what the download does (Hope: 767 s). So six
3231# 150 s audio windows of the stream being played are fetched (~150 MB, ~1 min)
3232# and window_align places each English track against them -- see
3233# test_window_align.py for why that, and not ffsubsync's own search. Only a
3234# track the windows could place is offered; the player keeps what it had for
3235# the rest. Results live in LIVE_SUBS_DIR, outside the library: a cache, not
3236# library state.
3237LIVE_SUBS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "live-subs")
3238LIVE_SOURCES = ("vidcore", "hdhub", "mp4")
3239LIVE_WINDOW_FRACTIONS = (0.1, 0.25, 0.4, 0.55, 0.7, 0.85)
3240# 6 x 150 s, not 3 x 300 s: on Hope 3 x 300 left one window that matched (a
3241# quiet stretch, then music the VAD called 97% speech) and no answer; six
3242# shorter windows gave three that agreed.
3243LIVE_WINDOW_SECONDS = 150
3244LIVE_MAX_TRACKS = 3
3245LIVE_READY_TTL = 3 * 86400
3246LIVE_NONE_RETRY = 30 * 60 # "no answer" may be a 503 that clears
3247_live_jobs: dict[str, str] = {}
3248_live_guard = threading.Lock()
3249# One at a time: each is ~150 MB of segments and six VAD passes on stortron.
3250_live_slot = threading.Semaphore(1)
3251
3252
3253def _live_request(kind: str, ident: str) -> Optional[tuple]:
3254 """(src, height) when this asks about a stream with no copy behind it,
3255 else None. A held copy is what plays, whatever `src` says: /vidcore/ and
3256 /hdhub/ redirect to it."""
3257 src = request.args.get("src")
3258 if src is None:
3259 return None
3260 if src not in LIVE_SOURCES:
3261 abort(404)
3262 target = _subs_target(kind, ident)
3263 if not target:
3264 abort(404)
3265 if _playable_path(*target):
3266 return None
3267 try:
3268 height = int(request.args.get("h") or VIDCORE_MAX_HEIGHT)
3269 except ValueError:
3270 abort(404)
3271 if height not in VIDCORE_HEIGHTS:
3272 height = VIDCORE_MAX_HEIGHT
3273 return src, height
3274
3275
3276def _live_key(kind: str, ident: str, src: str, height: int) -> str:
3277 return f"{kind}:{ident}:{src}:{height}"
3278
3279
3280def _live_dir(key: str) -> str:
3281 return os.path.join(LIVE_SUBS_DIR, re.sub(r"[^\w.-]", "_", key))
3282
3283
3284def _live_state(key: str) -> Optional[dict]:
3285 """The finished result for `key`, or None (none yet, or out of date)."""
3286 try:
3287 with open(os.path.join(_live_dir(key), "state.json")) as f:
3288 state = json.load(f)
3289 except Exception:
3290 return None
3291 age = time.time() - float(state.get("ts") or 0)
3292 ttl = LIVE_READY_TTL if state.get("status") == "ready" else LIVE_NONE_RETRY
3293 return state if age < ttl else None
3294
3295
3296def _start_live_sync(key: str, target) -> None:
3297 threading.Thread(target=target, name=f"livesubs-{key}", daemon=True).start()
3298
3299
3300def _live_answer(kind: str, ident: str, src: str, height: int) -> dict:
3301 key = _live_key(kind, ident, src, height)
3302 state = _live_state(key)
3303 if state is None:
3304 with _live_guard:
3305 start = key not in _live_jobs
3306 if start:
3307 _live_jobs[key] = "running"
3308 # Started outside the guard: the job takes it again when it finishes.
3309 if start:
3310 _start_live_sync(key, lambda: _run_live_sync(key, kind, ident,
3311 src, height))
3312 state = _live_state(key)
3313 if state is None:
3314 return {"status": "pending", "subtitles": []}
3315 return {"status": state["status"], "subtitles": [
3316 dict(t, url=(f"http://{request.host}/subs/{kind}/{ident}/{t['id']}.srt"
3317 f"?src={src}&h={height}"))
3318 for t in state.get("tracks") or []]}
3319
3320
3321def _live_file(kind: str, ident: str, sid: str, src: str,
3322 height: int) -> Optional[str]:
3323 key = _live_key(kind, ident, src, height)
3324 state = _live_state(key) or {}
3325 if sid not in {t["id"] for t in state.get("tracks") or []}:
3326 return None
3327 path = os.path.join(_live_dir(key), sid + ".srt")
3328 return path if os.path.exists(path) else None
3329
3330
3331def _run_live_sync(key: str, kind: str, ident: str, src: str, height: int) -> None:
3332 """The job: never raises, always leaves a state behind."""
3333 state = {"status": "none", "tracks": []}
3334 try:
3335 _, base, season, episode = _subs_target(kind, ident)
3336 with _live_slot:
3337 state = _live_sync(kind, base, season, episode, src, height,
3338 _live_dir(key))
3339 except Exception as exc:
3340 log.warning("livesubs: %s failed (%s)", key, exc)
3341 finally:
3342 state["ts"] = time.time()
3343 try:
3344 os.makedirs(_live_dir(key), exist_ok=True)
3345 tmp = os.path.join(_live_dir(key), "state.json.part")
3346 with open(tmp, "w") as f:
3347 json.dump(state, f)
3348 os.replace(tmp, os.path.join(_live_dir(key), "state.json"))
3349 except Exception as exc:
3350 log.warning("livesubs: %s state not saved (%s)", key, exc)
3351 with _live_guard:
3352 _live_jobs.pop(key, None)
3353 log.info("livesubs: %s -> %s (%s)", key, state["status"],
3354 ", ".join(f"{t['id']} {t['status']} {t['offset']:+.2f}s"
3355 for t in state.get("tracks") or []) or "nothing placed")
3356
3357
3358def _live_sync(kind: str, ident: str, season: int, episode: int, src: str,
3359 height: int, out_dir: str) -> dict:
3360 import window_align as wa # numpy: only this path needs it
3361 source = _live_source(kind, ident, season, episode, src, height)
3362 if not source:
3363 return {"status": "none", "tracks": []}
3364 tracks = _live_tracks(kind, ident, season, episode)
3365 if not tracks:
3366 return {"status": "none", "tracks": []}
3367 with tempfile.TemporaryDirectory(prefix="livesubs-") as work:
3368 windows = _live_windows(source, work)
3369 os.makedirs(out_dir, exist_ok=True)
3370 placed = []
3371 for sid, text in tracks:
3372 m = wa.align(wa.srt_cues(text), windows)
3373 if not m:
3374 log.info("livesubs: %s %s -- the windows do not agree", ident, sid)
3375 continue
3376 entry = {"id": sid, "lang": re.sub(r"\.\d+$", "", sid),
3377 "offset": m["offset"], "scale": m["scale"]}
3378 if _in_tolerance(m):
3379 entry["status"], body = "in_sync", text
3380 else:
3381 body = wa.apply(text, m["scale"], m["offset"])
3382 if not _in_tolerance(wa.align(wa.srt_cues(body), windows)):
3383 log.info("livesubs: %s %s -- correction does not hold", ident, sid)
3384 continue
3385 entry["status"] = "corrected"
3386 part = os.path.join(out_dir, sid + ".srt.part")
3387 with open(part, "w", encoding="utf-8") as f:
3388 f.write(body)
3389 os.replace(part, os.path.join(out_dir, sid + ".srt"))
3390 placed.append(entry)
3391 return {"status": "ready" if placed else "none", "tracks": placed}
3392
3393
3394def _live_tracks(kind: str, ident: str, season: int, episode: int) -> list:
3395 """[(id, text)] English tracks: wyzie's, else OpenSubtitles'."""
3396 meta = _library_meta(kind, ident, season, episode)
3397 if not meta:
3398 return []
3399
3400 def fetch(tracks):
3401 out = []
3402 for t in tracks:
3403 if t.get("language") != "en" or not t.get("url"):
3404 continue
3405 try:
3406 body = _fetch_subtitle(t["url"])
3407 except Exception as exc:
3408 log.info("livesubs: track failed (%s)", exc)
3409 continue
3410 if body:
3411 out.append(body.decode("utf-8", errors="replace"))
3412 if len(out) >= LIVE_MAX_TRACKS:
3413 break
3414 return out
3415
3416 texts = fetch(_wyzie_tracks(meta["tmdb_id"], season=season, episode=episode))
3417 if not texts:
3418 try:
3419 texts = fetch(_opensubs_for(kind, meta["tmdb_id"], season, episode))
3420 except Exception as exc:
3421 log.info("livesubs: opensubs skipped (%s)", exc)
3422 return [("en" if n == 0 else f"en.{n + 1}", t) for n, t in enumerate(texts)]
3423
3424
3425def _live_source(kind: str, ident: str, season: int, episode: int, src: str,
3426 height: int) -> Optional[dict]:
3427 """What the player is actually playing, for its audio. /mp4/ without a
3428 copy serves HdHub first, then vidcore -- the same order _serve_mp4 uses."""
3429 if src == "mp4":
3430 src = ("hdhub" if _hdhub_enabled()
3431 and _hdhub_pick(kind, ident, season, episode, height) else "vidcore")
3432 if src == "vidcore":
3433 master = _resolve_vidcore(kind, ident, season, episode)
3434 if not master:
3435 return None
3436 # Every variant carries the same audio on the same clock; the
3437 # smallest is the cheapest to sample (Hope 480p: 144 MB for 6 x 150 s).
3438 return {"kind": "hls", "url": _cap_variant(master, min(VIDCORE_HEIGHTS)),
3439 "headers": {"User-Agent": UA, "Referer": "https://vidcore.net/"}}
3440 picked = _hdhub_pick(kind, ident, season, episode, height)
3441 if not picked:
3442 return None
3443 f, probe = picked
3444 audio = _probe_audio(probe)
3445 english = [a for a in audio if _audio_lang(a) in ("eng", "en")] or audio
3446 try:
3447 duration = float(((probe or {}).get("format") or {}).get("duration"))
3448 except (TypeError, ValueError):
3449 return None
3450 return {"kind": "file", "url": f.url, "headers": {}, "duration": duration,
3451 "audio": english[0]["index"] if english else None}
3452
3453
3454def _hls_segments(body: str, base: str) -> tuple:
3455 """(init url or None, [(start seconds, url)]) from a media playlist."""
3456 init, segs, t, dur = None, [], 0.0, None
3457 for line in body.splitlines():
3458 line = line.strip()
3459 if line.startswith("#EXT-X-MAP:"):
3460 m = re.search(r'URI="([^"]+)"', line)
3461 init = urljoin(base, m.group(1)) if m else None
3462 elif line.startswith("#EXTINF:"):
3463 try:
3464 dur = float(line[8:].split(",")[0])
3465 except ValueError:
3466 dur = None
3467 elif line and not line.startswith("#") and dur is not None:
3468 segs.append((round(t, 3), urljoin(base, line)))
3469 t += dur
3470 dur = None
3471 return init, segs
3472
3473
3474def _window_plan(segs: list, fractions=LIVE_WINDOW_FRACTIONS,
3475 seconds: float = LIVE_WINDOW_SECONDS) -> list:
3476 """[(first, end)] segment index ranges covering each window."""
3477 if len(segs) < 2:
3478 return []
3479 starts = [s for s, _ in segs]
3480 total = starts[-1] + (starts[-1] - starts[-2])
3481 plan = []
3482 for f in fractions:
3483 i0 = bisect.bisect_left(starts, total * f)
3484 i1 = bisect.bisect_left(starts, total * f + seconds)
3485 if i0 < i1:
3486 plan.append((i0, i1))
3487 return plan
3488
3489
3490def _probe_start(path: str, stream: Optional[str] = None) -> float:
3491 args = ["ffprobe", "-v", "error"]
3492 args += (["-select_streams", stream, "-show_entries", "stream=start_time"]
3493 if stream else ["-show_entries", "format=start_time"])
3494 out = subprocess.run(args + ["-of", "csv=p=0", path], capture_output=True,
3495 text=True, timeout=60).stdout
3496 return float(out.strip().splitlines()[0].strip(","))
3497
3498
3499def _hls_window_wavs(source: dict, work: str) -> list:
3500 """[(position, wav)] for an HLS variant. ffmpeg cannot seek vidcore's
3501 muxed fMP4 (it read for 2.5 min and wrote nothing), so each window is the
3502 init segment plus the segments covering it, concatenated: a valid fMP4
3503 whose own timestamps place it. Position = its audio start minus the
3504 stream's start, i.e. the player's clock."""
3505 headers = source["headers"]
3506 r = requests.get(source["url"], headers=headers, timeout=REQUEST_TIMEOUT)
3507 r.raise_for_status()
3508 init, segs = _hls_segments(r.text, source["url"])
3509 init_bytes = b""
3510 if init:
3511 init_bytes = requests.get(init, headers=headers, timeout=REQUEST_TIMEOUT).content
3512
3513 def get(url):
3514 resp = requests.get(url, headers=headers, timeout=30)
3515 resp.raise_for_status()
3516 return resp.content
3517
3518 def assemble(name, urls):
3519 path = os.path.join(work, name)
3520 with ThreadPoolExecutor(max_workers=4) as pool:
3521 parts = list(pool.map(get, urls))
3522 with open(path, "wb") as f:
3523 f.write(init_bytes)
3524 for p in parts:
3525 f.write(p)
3526 return path
3527
3528 out = []
3529 if len(segs) < 2:
3530 return out
3531 base = _probe_start(assemble("first.mp4", [segs[0][1]]))
3532 for n, (i0, i1) in enumerate(_window_plan(segs)):
3533 try:
3534 path = assemble(f"win{n}.mp4", [u for _, u in segs[i0:i1]])
3535 pos = _probe_start(path, "a:0") - base
3536 wav = os.path.join(work, f"w{n}.wav")
3537 subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", path, "-vn",
3538 "-ac", "1", "-ar", "16000", "-af", "aresample=async=1",
3539 wav], check=True, capture_output=True, timeout=300)
3540 out.append((pos, wav))
3541 except Exception as exc:
3542 log.info("livesubs: window %d skipped (%s)", n, exc)
3543 return out
3544
3545
3546def _file_window_wavs(source: dict, work: str) -> list:
3547 """[(position, wav)] for a progressive file: ranged seeks, which a file
3548 (unlike vidcore's HLS) allows. The English audio stream, whatever the
3549 player has selected: timing is the video's either way."""
3550 out = []
3551 for n, f in enumerate(LIVE_WINDOW_FRACTIONS):
3552 pos = int(source["duration"] * f)
3553 wav = os.path.join(work, f"w{n}.wav")
3554 cmd = ["ffmpeg", "-v", "error", "-y", "-rw_timeout", "30000000"]
3555 if source.get("headers"):
3556 cmd += ["-headers", "".join(f"{k}: {v}\r\n"
3557 for k, v in source["headers"].items())]
3558 cmd += ["-ss", str(pos), "-t", str(LIVE_WINDOW_SECONDS), "-i", source["url"]]
3559 cmd += (["-map", f"0:{source['audio']}"] if source.get("audio") is not None
3560 else ["-vn"])
3561 cmd += ["-ac", "1", "-ar", "16000",
3562 "-af", "aresample=async=1:first_pts=0", wav]
3563 try:
3564 subprocess.run(cmd, check=True, capture_output=True, timeout=300)
3565 out.append((float(pos), wav))
3566 except Exception as exc:
3567 log.info("livesubs: window %d skipped (%s)", n, exc)
3568 return out
3569
3570
3571def _live_windows(source: dict, work: str) -> list:
3572 """[(position, speech array)] -- VAD by ffsubsync, one window at a time."""
3573 import numpy as np
3574 wavs = (_hls_window_wavs if source["kind"] == "hls" else _file_window_wavs)(
3575 source, work)
3576 out = []
3577 for pos, wav in wavs:
3578 try:
3579 subprocess.run([SUBSYNC_BIN, wav, "--serialize-speech"], cwd=work,
3580 capture_output=True, timeout=SUBSYNC_TIMEOUT)
3581 out.append((pos, np.load(os.path.splitext(wav)[0] + ".npz")["speech"]))
3582 except Exception as exc:
3583 log.info("livesubs: no speech from %s (%s)", os.path.basename(wav), exc)
3584 return out
3585
3586
32213587# ------------------------------ HdHub (direct files) ------------------------
32223588# The second live source, and the first that hands over FILES rather than a
32233589# player. Found 2026-09-28 in cinewave's bundle (watch.cinewave.qzz.io), where it
conftest.py +2 -0
……@@ -30,6 +30,8 @@ def no_production_history(tmp_path, monkeypatch):
3030 monkeypatch.setattr(addon, "_subsync_available", lambda: False)
3131 # Per-file source rejections (2026-10-14) sit beside addon.py too.
3232 monkeypatch.setattr(addon, "SOURCE_REJECTS_FILE", str(tmp_path / "source_rejects.json"))
33 # Live-stream subtitle results (2026-10-18) sit beside addon.py too.
34 monkeypatch.setattr(addon, "LIVE_SUBS_DIR", str(tmp_path / "live-subs"))
3335 yield
3436
3537
test_live_subsync.py +276 -0
……@@ -0,0 +1,276 @@
1"""Subtitles synced to a LIVE stream -- one with no copy to measure (2026-10-18).
2
3The library sync measures a track against the copy's own audio. A stream
4being played from vidcore or HdHub has no copy yet, so Kodi got wyzie's raw
5tracks, cut for some other release. Here the server takes six 150 s audio
6windows of the very stream being played (vidcore: the 480p variant of the
7same master, segments fetched directly because ffmpeg cannot seek its muxed
8fMP4; HdHub: a ranged seek into the file, ENGLISH audio), and window_align
9places each English track against them.
10
11 GET /subs/<kind>/<ident>.json?src=vidcore|hdhub|mp4&h=<height>
12 not held -> {"status": "pending"|"ready"|"none", "subtitles": [...]}
13 held -> the library answer, src ignored (the copy is what plays)
14
15Only tracks the windows could place are offered: in sync as fetched, or
16corrected. Anything else is not offered and the player keeps what it had.
17A track without `src` behaves exactly as before, so an older Jacktook is
18unaffected. Nothing here may start an ingest.
19
20Run: .venv/bin/python3 -m pytest test_live_subsync.py -q
21"""
22
23import os
24
25import numpy as np
26import pytest
27
28import addon
29import window_align as wa
30
31
32TMDB = {
33 "tv/1": {"name": "Show", "first_air_date": "2020-01-01", "seasons": []},
34 "tv/1/season/2/episode/6": {"name": "Six", "runtime": 30},
35 "movie/2": {"title": "Film", "release_date": "2021-05-05", "runtime": 120},
36}
37
38
39@pytest.fixture(autouse=True)
40def isolated(tmp_path, monkeypatch):
41 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib"))
42 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json"))
43 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", str(tmp_path / "verified.json"))
44 monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path / "mp4-cache"))
45 monkeypatch.setattr(addon, "_duration_cache", {})
46 monkeypatch.setattr(addon, "_library_meta_cache", {})
47 monkeypatch.setattr(addon, "_mp4_jobs", {})
48 monkeypatch.setattr(addon, "_live_jobs", {})
49 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: TMDB[path])
50 monkeypatch.setattr(addon, "_media_duration", lambda p: 7200.0)
51
52 def refuse(*a, **k):
53 raise AssertionError("a subtitle request started a download")
54 monkeypatch.setattr(addon, "_start_mp4_job", refuse)
55 monkeypatch.setattr(addon, "_queue_next_episode", refuse)
56 monkeypatch.setattr(addon, "_ensure_ingest", refuse)
57 yield
58
59
60@pytest.fixture
61def client():
62 return addon.app.test_client()
63
64
65# ---------------------------------------------------------- a fake stream
66
67def _cues(total=7200.0):
68 rng = np.random.default_rng(3)
69 t, out = 3.0, []
70 while t < total:
71 out.append((t, t + 2.0 + rng.uniform(0, 1.5)))
72 t += 7.0 + rng.uniform(-2.5, 2.5)
73 return out
74
75
76CUES = _cues()
77POSITIONS = [720.0, 1800.0, 2880.0, 3960.0, 5040.0, 6120.0]
78
79
80def _cut(t):
81 """A different edit: scenes added and removed, so the shift changes along
82 the film and no single offset+scale repairs it."""
83 for edge, shift in ((1500.0, 0.0), (3500.0, -11.0), (5000.0, 23.0)):
84 if t < edge:
85 return shift
86 return -37.0
87
88
89def _srt(cues, scale=1.0, offset=0.0):
90 out = []
91 for n, (a, b) in enumerate(cues, 1):
92 out.append(f"{n}\n{wa._fmt(a * scale + offset)} --> {wa._fmt(b * scale + offset)}\nline {n}\n")
93 return "\n".join(out)
94
95
96def _windows():
97 """Speech exactly where CUES are: the stream's truth."""
98 wins = []
99 for p in POSITIONS:
100 w = np.zeros(15000)
101 for a, b in CUES:
102 i, j = int(round((a - p) * 100)), int(round((b - p) * 100))
103 i, j = max(i, 0), min(j, 15000)
104 if i < j:
105 w[i:j] = 1.0
106 wins.append((p, w))
107 return wins
108
109
110@pytest.fixture
111def stream(monkeypatch):
112 """Source, windows and English tracks stubbed; the sync runs inline."""
113 state = {"started": [], "tracks": [
114 ("en", _srt(CUES, offset=-7.0)), # needs +7.0 -> corrected
115 ("en.2", _srt(CUES)), # already right -> in sync
116 ("en.3", _srt([(a + _cut(a), b + _cut(a)) for a, b in CUES])), # another cut
117 ], "source": {"kind": "hls", "url": "http://cdn.invalid/v.m3u8"}}
118 monkeypatch.setattr(addon, "_live_source",
119 lambda *a, **k: state["source"])
120 monkeypatch.setattr(addon, "_live_windows", lambda source, work: _windows())
121 monkeypatch.setattr(addon, "_live_tracks", lambda *a, **k: state["tracks"])
122
123 def inline(key, target):
124 state["started"].append(key)
125 target()
126 monkeypatch.setattr(addon, "_start_live_sync", inline)
127 return state
128
129
130def _hold(kind, season=0, episode=0, tmdb="2"):
131 meta = addon._library_meta(kind, f"tmdb:{tmdb}", season, episode)
132 path = addon._library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
133 season, episode, meta["episode_title"])
134 os.makedirs(os.path.dirname(path), exist_ok=True)
135 with open(path, "wb") as f:
136 f.write(b"0" * 1000)
137 with open(os.path.splitext(path)[0] + ".en.srt", "w") as f:
138 f.write("library")
139 return path
140
141
142# ------------------------------------------------------------------ route
143
144def test_without_src_the_answer_is_unchanged(client, stream):
145 r = client.get("/subs/movie/tmdb:2.json")
146 assert r.get_json() == {"subtitles": []}
147 assert stream["started"] == []
148
149
150def test_a_live_request_runs_one_sync(client, stream):
151 client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080")
152 client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080")
153 assert len(stream["started"]) == 1
154
155
156def test_pending_while_the_sync_runs(client, monkeypatch, stream):
157 monkeypatch.setattr(addon, "_start_live_sync", lambda key, target: None)
158 r = client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080").get_json()
159 assert r == {"status": "pending", "subtitles": []}
160
161
162def test_ready_offers_only_the_tracks_the_windows_placed(client, stream):
163 r = client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080").get_json()
164 assert r["status"] == "ready"
165 by_id = {s["id"]: s for s in r["subtitles"]}
166 assert set(by_id) == {"en", "en.2"}, "offered a track from another cut"
167 assert by_id["en"]["status"] == "corrected"
168 assert by_id["en"]["offset"] == pytest.approx(7.0, abs=0.05)
169 assert by_id["en.2"]["status"] == "in_sync"
170 assert all(s["lang"] == "en" for s in r["subtitles"])
171
172
173def test_a_corrected_track_is_served_moved(client, stream):
174 r = client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080").get_json()
175 url = next(s["url"] for s in r["subtitles"] if s["id"] == "en")
176 body = client.get(url.split("localhost", 1)[1]).get_data(as_text=True)
177 # Not the first cue: shifted -7 s, it was clamped to 0 when written.
178 assert wa.srt_cues(body)[5][0] == pytest.approx(CUES[5][0], abs=0.05)
179
180
181def test_an_episode_is_its_own_sync(client, stream):
182 client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080")
183 client.get("/subs/series/tmdb:1:2:6.json?src=vidcore&h=1080")
184 assert len(stream["started"]) == 2
185
186
187def test_each_source_is_its_own_sync(client, stream):
188 """vidcore and HdHub are different releases: what fits one need not fit
189 the other."""
190 client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080")
191 client.get("/subs/movie/tmdb:2.json?src=hdhub&h=1080")
192 assert len(stream["started"]) == 2
193
194
195def test_nothing_placeable_is_none(client, stream):
196 stream["tracks"] = stream["tracks"][2:]
197 r = client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080").get_json()
198 assert r == {"status": "none", "subtitles": []}
199
200
201def test_no_source_is_none(client, stream):
202 stream["source"] = None
203 r = client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080").get_json()
204 assert r == {"status": "none", "subtitles": []}
205
206
207def test_a_held_copy_answers_from_the_library_whatever_src_says(client, stream):
208 _hold("movie")
209 r = client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080").get_json()
210 assert [s["id"] for s in r["subtitles"]] == ["en"]
211 assert stream["started"] == []
212
213
214def test_a_finished_sync_is_kept_on_disk(client, stream, monkeypatch):
215 client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080")
216 monkeypatch.setattr(addon, "_live_jobs", {}) # a restart
217 r = client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080").get_json()
218 assert r["status"] == "ready" and len(stream["started"]) == 1
219
220
221def test_an_unknown_src_or_track_is_404(client, stream):
222 assert client.get("/subs/movie/tmdb:2.json?src=elsewhere").status_code == 404
223 client.get("/subs/movie/tmdb:2.json?src=vidcore&h=1080")
224 assert client.get("/subs/movie/tmdb:2/en.3.srt?src=vidcore&h=1080").status_code == 404
225 assert client.get("/subs/movie/tmdb:2/..%2Fstate.srt?src=vidcore&h=1080").status_code == 404
226
227
228# --------------------------------------------------------- the HLS windows
229
230PLAYLIST = """#EXTM3U
231#EXT-X-TARGETDURATION:6
232#EXT-X-PLAYLIST-TYPE:VOD
233#EXT-X-MAP:URI="https://seg.invalid/init.mp4"
234""" + "".join(f"#EXTINF:6.0,\nhttps://seg.invalid/s{i}.m4s\n" for i in range(1200)) \
235 + "#EXT-X-ENDLIST\n"
236
237
238def test_the_playlist_gives_init_segments_and_their_starts():
239 init, segs = addon._hls_segments(PLAYLIST, "https://cdn.invalid/v/index.m3u8")
240 assert init == "https://seg.invalid/init.mp4"
241 assert len(segs) == 1200
242 assert segs[0] == (0.0, "https://seg.invalid/s0.m4s")
243 assert segs[10] == (60.0, "https://seg.invalid/s10.m4s")
244
245
246def test_each_window_takes_the_segments_that_cover_it():
247 _, segs = addon._hls_segments(PLAYLIST, "https://cdn.invalid/v/index.m3u8")
248 plan = addon._window_plan(segs, fractions=(0.1, 0.5), seconds=150)
249 assert [p[0] for p in plan] == [120, 600] # segment indexes
250 assert all(p[1] - p[0] == 25 for p in plan) # 150 s of 6 s segments
251
252
253# -------------------------------------------------------- the English tracks
254
255def test_live_tracks_are_wyzie_english(monkeypatch):
256 monkeypatch.setattr(addon, "_wyzie_tracks", lambda *a, **k: [
257 {"language": "en", "url": "u1"}, {"language": "fr", "url": "u2"},
258 {"language": "en", "url": "u3"}])
259 monkeypatch.setattr(addon, "_fetch_subtitle", lambda url: f"body {url}".encode())
260 monkeypatch.setattr(addon, "_opensubs_for",
261 lambda *a, **k: pytest.fail("asked OpenSubtitles"))
262 got = addon._live_tracks("movie", "tmdb:2", 0, 0)
263 assert got == [("en", "body u1"), ("en.2", "body u3")]
264
265
266def test_live_tracks_fall_back_to_opensubtitles(monkeypatch):
267 monkeypatch.setattr(addon, "_wyzie_tracks", lambda *a, **k: [
268 {"language": "en", "url": "u1"}])
269
270 def wyzie_503(url):
271 raise RuntimeError("503")
272 monkeypatch.setattr(addon, "_fetch_subtitle",
273 lambda url: wyzie_503(url) if url == "u1" else b"os")
274 monkeypatch.setattr(addon, "_opensubs_for",
275 lambda kind, tmdb, s=0, e=0: [{"language": "en", "url": "o1"}])
276 assert addon._live_tracks("movie", "tmdb:2", 0, 0) == [("en", "os")]
test_suite_isolation.py +4 -0
……@@ -34,3 +34,7 @@ def test_subtitle_sync_state_is_never_production():
3434
3535def test_source_rejects_are_never_production():
3636 assert os.path.dirname(os.path.abspath(addon.SOURCE_REJECTS_FILE)) != HERE
37
38
39def test_live_subtitles_are_never_production():
40 assert os.path.dirname(os.path.abspath(addon.LIVE_SUBS_DIR)) != HERE