OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed / test_vidcore_prefetch.py

222 lines · python ref:
"""Resolving off the play path, and measuring where a resolve's time goes.

Measured 2026-08-10 over four runs: browser launch 618-722ms and page load
1112-1215ms are steady, while vidcore's own player takes 5.3s to 15s+ to emit
the m3u8. So the cost is not startup -- it is the site, and it is variable. The
play path gives us Kodi's ~30s demuxer budget to absorb that, which is why a
resolve started when the user OPENS the source list (rather than when they press
play) is the difference between comfortable and marginal.

The instrumentation exists because one real failure took 57s, and a resolve that
finds nothing should abort at ~24s. Node's self-reported total vs the wall time
around the subprocess isolates whether the missing time is inside node or in the
xvfb-run/spawn wrapper around it.

Run:  .venv/bin/python3 -m pytest test_vidcore_prefetch.py -q
"""

import threading
import time

import pytest

import addon

KIND, IDENT, S, E = "series", "tmdb:138502", 2, 7
KEY = f"{KIND}:{IDENT}:{S}:{E}"


@pytest.fixture(autouse=True)
def clean(monkeypatch):
    monkeypatch.setattr(addon, "_vidcore_cache", {})
    monkeypatch.setattr(addon, "_vidcore_rep", {})
    monkeypatch.setattr(addon, "_prefetch_sem", threading.Semaphore(1))
    yield


# --- instrumentation: is the missing time inside node, or around it? --------

def test_overhead_is_wall_minus_node_total():
    out = {"timing": {"total_ms": 24000}}
    assert addon._resolve_overhead_ms(out, 57000) == 33000


def test_overhead_is_none_without_timing():
    assert addon._resolve_overhead_ms({}, 57000) is None
    assert addon._resolve_overhead_ms({"timing": {}}, 57000) is None


def test_overhead_survives_a_junk_timing_block():
    """Never let a diagnostic field break a resolve that otherwise worked."""
    assert addon._resolve_overhead_ms({"timing": {"total_ms": "soon"}}, 1000) is None
    assert addon._resolve_overhead_ms({"timing": None}, 1000) is None


def test_resolver_reports_its_phases():
    import os
    mjs = os.path.join(os.path.dirname(os.path.abspath(addon.__file__)),
                       "node_helper", "vidcore_resolve.mjs")
    with open(mjs) as f:
        src = f.read()
    for field in ("timing", "launch_ms", "goto_ms", "poll_ms", "total_ms"):
        assert field in src, field


# --- prefetch ---------------------------------------------------------------

def test_prefetch_starts_a_resolve(monkeypatch):
    seen = []
    monkeypatch.setattr(addon, "_resolve_vidcore",
                        lambda *a, **k: seen.append(a) or "url")
    t = addon._prefetch_vidcore(KIND, IDENT, S, E)
    t.join(timeout=5)
    assert seen == [(KIND, IDENT, S, E)]


def test_prefetch_skips_a_cached_title(monkeypatch):
    """The whole point is a warm cache; do not re-resolve one we already have."""
    addon._vidcore_cache[KEY] = (time.time() + 600, "https://cdn/master.m3u8")
    monkeypatch.setattr(addon, "_resolve_vidcore",
                        lambda *a, **k: pytest.fail("resolved a cached title"))
    assert addon._prefetch_vidcore(KIND, IDENT, S, E) is None


def test_prefetch_skips_an_expired_cache_entry(monkeypatch):
    addon._vidcore_cache[KEY] = (time.time() - 1, "https://cdn/stale.m3u8")
    monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "url")
    t = addon._prefetch_vidcore(KIND, IDENT, S, E)
    assert t is not None
    t.join(timeout=5)


def test_prefetch_is_bounded(monkeypatch):
    """Browsing quickly must not spawn a browser per episode looked at."""
    monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "url")
    addon._prefetch_sem.acquire()          # the one slot is busy
    assert addon._prefetch_vidcore(KIND, IDENT, S, E) is None


def test_the_slot_is_released_after_a_resolve(monkeypatch):
    monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "url")
    addon._prefetch_vidcore(KIND, IDENT, S, E).join(timeout=5)
    assert addon._prefetch_sem.acquire(blocking=False) is True


def test_the_slot_is_released_when_a_resolve_raises(monkeypatch):
    """A crashing resolver must not permanently disable prefetching."""
    def boom(*a, **k):
        raise RuntimeError("resolver died")
    monkeypatch.setattr(addon, "_resolve_vidcore", boom)
    addon._prefetch_vidcore(KIND, IDENT, S, E).join(timeout=5)
    assert addon._prefetch_sem.acquire(blocking=False) is True


# --- wiring: opening a source list is what triggers it ----------------------

def test_stream_request_triggers_a_prefetch(monkeypatch):
    seen = []
    monkeypatch.setattr(addon, "_prefetch_vidcore",
                        lambda *a, **k: seen.append(a))
    with addon.app.test_request_context("/stream/series/fxl:138502:2:7.json"):
        out = []
        addon._append_vidcore_row(out, KIND, IDENT, S, E)
    assert seen == [(KIND, IDENT, S, E)]
    assert len(out) == len(addon.VIDCORE_HEIGHTS)


def test_a_known_miss_prefetches_nothing(monkeypatch):
    monkeypatch.setattr(addon, "_prefetch_vidcore",
                        lambda *a, **k: pytest.fail("prefetched a known miss"))
    addon._vidcore_rep[KEY] = {"status": "miss", "ts": time.time(),
                               "strikes": addon.VIDCORE_MISS_STRIKES}
    with addon.app.test_request_context("/stream/series/fxl:138502:2:7.json"):
        out = []
        addon._append_vidcore_row(out, KIND, IDENT, S, E)
    assert out == []


# --- the play deadline ------------------------------------------------------
# Measured 2026-08-12 (Supergirl, tmdb:1081003): /stream kicked off a prefetch
# that ran COLD -- launch=28866ms after 25h idle, against 595-760ms on every warm
# resolve -- and took 57.7s. The play request arrived 4s later and blocked on the
# per-title lock for all of it, then paid its own 11.4s resolve on top: 68s. Kodi
# gives an HTTP open 20s (CCurlFile::Stat and CurlFile::Open each timed out at
# exactly that), so it had already given up 2.2s before the addon answered.
#
# The prefetch exists to take resolving OFF the play path; the shared lock put it
# back on, and made it strictly worse than no prefetch at all. So a play request
# waits a bounded time and then gives up. The resolve it was waiting on still
# finishes and fills the cache, so the next click is instant.

def test_a_play_request_does_not_queue_behind_a_resolve_in_flight(monkeypatch):
    monkeypatch.setattr(addon, "VIDCORE_PLAY_DEADLINE", 0.2)
    lock = threading.Lock()
    monkeypatch.setattr(addon, "_vidcore_locks", {KEY: lock})
    monkeypatch.setattr(addon, "_resolve_vidcore_locked",
                        lambda *a, **k: pytest.fail("started a second resolve"))
    lock.acquire()                       # a prefetch is mid-resolve
    started = time.time()
    got = addon._resolve_vidcore(KIND, IDENT, S, E,
                                 wait=addon.VIDCORE_PLAY_DEADLINE)
    waited = time.time() - started
    lock.release()
    assert got is None
    assert waited < 2, f"waited {waited:.1f}s on a lock it should have abandoned"


def test_a_play_request_takes_a_cache_the_holder_filled(monkeypatch):
    """Giving up must still prefer an answer over none."""
    lock = threading.Lock()
    monkeypatch.setattr(addon, "_vidcore_locks", {KEY: lock})
    lock.acquire()
    addon._vidcore_cache[KEY] = (time.time() + 600, "https://cdn/master.m3u8")
    got = addon._resolve_vidcore(KIND, IDENT, S, E, wait=0.2)
    lock.release()
    assert got == "https://cdn/master.m3u8"


def test_a_background_resolve_still_waits_for_the_lock(monkeypatch):
    """Only the play path has a deadline. An ingest has all the time it needs."""
    lock = threading.Lock()
    monkeypatch.setattr(addon, "_vidcore_locks", {KEY: lock})
    monkeypatch.setattr(addon, "_resolve_vidcore_locked",
                        lambda *a, **k: "https://cdn/late.m3u8")
    lock.acquire()
    out = []
    t = threading.Thread(target=lambda: out.append(
        addon._resolve_vidcore(KIND, IDENT, S, E)))
    t.start()
    t.join(timeout=0.5)
    assert out == [], "a background resolve gave up on the lock"
    lock.release()
    t.join(timeout=5)
    assert out == ["https://cdn/late.m3u8"]


@pytest.mark.parametrize("path,args", [
    ("/vidcore/movie/tmdb:1081003.m3u8?h=1080", ("movie", "tmdb:1081003")),
    ("/mp4/movie/tmdb:1081003.mp4?h=1080", ("movie", "tmdb:1081003", 0, 0)),
])
def test_the_play_routes_pass_the_deadline(monkeypatch, path, args):
    seen = {}

    def resolve(*a, **k):
        seen.update(kwargs=k, args=a)
        return None                      # 404 is fine; we only check the call
    monkeypatch.setattr(addon, "_resolve_vidcore", resolve)
    monkeypatch.setattr(addon, "_playable_path", lambda *a, **k: None)
    monkeypatch.setattr(addon, "_ensure_ingest", lambda *a, **k: None)
    addon.app.test_client().get(path)
    assert seen.get("kwargs", {}).get("wait") == addon.VIDCORE_PLAY_DEADLINE


def test_prefetch_failure_never_breaks_the_stream_response(monkeypatch):
    """/stream must answer even if kicking off the prefetch throws."""
    def boom(*a, **k):
        raise RuntimeError("no threads left")
    monkeypatch.setattr(addon, "_prefetch_vidcore", boom)
    with addon.app.test_request_context("/stream/series/fxl:138502:2:7.json"):
        out = []
        addon._append_vidcore_row(out, KIND, IDENT, S, E)
    assert len(out) == len(addon.VIDCORE_HEIGHTS)