OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / ba80881

A play request no longer queues behind a prefetch (0.13.1)
Measured on Supergirl (tmdb:1081003), 2026-08-12. /stream started 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, blocked on the per-title lock for all of it, then paid its own 11.4s resolve on top and answered at 68s. Kodi allows an HTTP open 20s (CCurlFile::Stat and CurlFile::Open each timed out at exactly that), so it had given up 2.2s earlier, left a busy dialog up, and the user saw a hang. The prefetch exists to take resolving OFF the play path. Sharing the lock put it back on and made that play slower than no prefetch at all -- its own resolve would have finished in 11.4s and worked. So _resolve_vidcore takes a `wait`: the play routes pass 8s, everything in the background keeps waiting as long as it takes. Giving up prefers a cached answer if the holder produced one, and the resolve being waited on still completes and fills the cache -- so the cost is one click, not the title. Not fixed here: the cold start itself. The 2026-08-10 conclusion that a warm browser was the wrong fix came from back-to-back probes, which never saw a cold launch; 28.9s of the 57.7s was one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ba8088199bdce8dfeddaa65d5354e46198f01da8
by plankatron <fleeky@gmail.com> · 2026-08-13T03:08:01.000Z

Changed files (2) +118 -7

addon.py +43 -7
……@@ -595,7 +595,7 @@ TV_GENRES = [
595595
596596MANIFEST = {
597597 "id": "org.tabertron.vsembed",
598 "version": "0.13.0",
598 "version": "0.13.1",
599599 "name": "Tabertron VsEmbed",
600600 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
601601 "types": ["movie", "series"],
……@@ -872,6 +872,25 @@ def _cap_variant(master_url: str, max_height: int = VIDCORE_MAX_HEIGHT) -> str:
872872VIDCORE_PREFETCH_MAX = 1
873873_prefetch_sem = threading.Semaphore(VIDCORE_PREFETCH_MAX)
874874
875# How long a PLAY request may queue behind a resolve already in flight.
876#
877# The prefetch above shares a per-title lock with the play path, so until this
878# existed, pressing play meant waiting out whatever the prefetch was doing --
879# with no bound short of VIDCORE_TIMEOUT. Measured 2026-08-12 on Supergirl
880# (tmdb:1081003): /stream started a prefetch that ran COLD (launch=28866ms after
881# 25h idle, against 595-760ms on every warm resolve) and took 57.7s; the play
882# request arrived 4s later, queued for all of it, then paid its own 11.4s resolve
883# on top and answered at 68s. Kodi allows an HTTP open 20s -- CCurlFile::Stat and
884# CurlFile::Open each timed out at exactly that -- so it had given up 2.2s before
885# the answer arrived. The prefetch, whose entire purpose is to take resolving off
886# the play path, had put it back on and made that play slower than no prefetch at
887# all (11.4s, which would have worked).
888#
889# 8s leaves room to inherit a resolve that is nearly done, while leaving over half
890# of Kodi's budget for the redirect and the CDN's own first bytes. Overrunning it
891# is not a slower play, it is a failed one.
892VIDCORE_PLAY_DEADLINE = 8
893
875894
876895def _prefetch_vidcore(kind: str, ident: str, season: int = 0,
877896 episode: int = 0) -> Optional[threading.Thread]:
……@@ -897,8 +916,14 @@ def _prefetch_vidcore(kind: str, ident: str, season: int = 0,
897916 return t
898917
899918
900def _resolve_vidcore(kind: str, ident: str, season: int = 0, episode: int = 0) -> Optional[str]:
901 """`ident` is `tt<n>` (cinemeta path) or `tmdb:<n>` (fxl:/fmv: catalog paths)."""
919def _resolve_vidcore(kind: str, ident: str, season: int = 0, episode: int = 0,
920 wait: Optional[float] = None) -> Optional[str]:
921 """`ident` is `tt<n>` (cinemeta path) or `tmdb:<n>` (fxl:/fmv: catalog paths).
922
923 `wait` bounds how long to queue behind a resolve someone else already
924 started. None means wait as long as it takes, which is right for anything
925 running in the background and wrong for anything a player is waiting on.
926 """
902927 key = f"{kind}:{ident}:{season}:{episode}"
903928 now = time.time()
904929 hit = _vidcore_cache.get(key)
……@@ -912,11 +937,20 @@ def _resolve_vidcore(kind: str, ident: str, season: int = 0, episode: int = 0) -
912937 # pure waste: hold a per-title lock so the followers wait and take the cache.
913938 with _vidcore_locks_guard:
914939 lock = _vidcore_locks.setdefault(key, threading.Lock())
915 with lock:
940 if not (lock.acquire() if wait is None else lock.acquire(timeout=wait)):
941 # Someone else is still working. Take a cached answer if one appeared
942 # while we waited, otherwise give up: their resolve carries on and fills
943 # the cache, so the cost of stopping here is one click, not the title.
944 log.info("vidcore: %s in flight past %ss — not waiting", key, wait)
945 hit = _vidcore_cache.get(key)
946 return hit[1] if hit and hit[0] > time.time() else None
947 try:
916948 hit = _vidcore_cache.get(key) # a waiter may have filled it
917949 if hit and hit[0] > time.time():
918950 return hit[1]
919951 return _resolve_vidcore_locked(key, kind, ident, season, episode)
952 finally:
953 lock.release()
920954
921955
922956def _master_playlist(master_url: str,
……@@ -1109,7 +1143,7 @@ def vidcore_movie(ident: str):
11091143 local = _local_redirect("movie", ident, 0, 0, height)
11101144 if local:
11111145 return local
1112 url = _resolve_vidcore("movie", ident)
1146 url = _resolve_vidcore("movie", ident, wait=VIDCORE_PLAY_DEADLINE)
11131147 if not url:
11141148 abort(404)
11151149 # Playing is the honest "I chose this" signal, so keep a copy of what is
……@@ -1129,7 +1163,8 @@ def vidcore_series(ident: str):
11291163 local = _local_redirect("series", m.group(1), season, episode, height)
11301164 if local:
11311165 return local
1132 url = _resolve_vidcore("series", m.group(1), season, episode)
1166 url = _resolve_vidcore("series", m.group(1), season, episode,
1167 wait=VIDCORE_PLAY_DEADLINE)
11331168 if not url:
11341169 abort(404)
11351170 _ensure_ingest("series", m.group(1), season, episode, height=height)
……@@ -1641,7 +1676,8 @@ def _serve_mp4(kind: str, ident: str, season: int, episode: int):
16411676 # surface as a playback failure while the copy silently proceeded. The same
16421677 # URL serves the local file once it lands, so the row upgrades itself.
16431678 _ensure_ingest(kind, ident, season, episode, height)
1644 url = _resolve_vidcore(kind, ident, season, episode)
1679 url = _resolve_vidcore(kind, ident, season, episode,
1680 wait=VIDCORE_PLAY_DEADLINE)
16451681 if not url:
16461682 abort(404)
16471683 return _serve_vidcore(url)
test_vidcore_prefetch.py +75 -0
……@@ -135,6 +135,81 @@ def test_a_known_miss_prefetches_nothing(monkeypatch):
135135 assert out == []
136136
137137
138# --- the play deadline ------------------------------------------------------
139# Measured 2026-08-12 (Supergirl, tmdb:1081003): /stream kicked off a prefetch
140# that ran COLD -- launch=28866ms after 25h idle, against 595-760ms on every warm
141# resolve -- and took 57.7s. The play request arrived 4s later and blocked on the
142# per-title lock for all of it, then paid its own 11.4s resolve on top: 68s. Kodi
143# gives an HTTP open 20s (CCurlFile::Stat and CurlFile::Open each timed out at
144# exactly that), so it had already given up 2.2s before the addon answered.
145#
146# The prefetch exists to take resolving OFF the play path; the shared lock put it
147# back on, and made it strictly worse than no prefetch at all. So a play request
148# waits a bounded time and then gives up. The resolve it was waiting on still
149# finishes and fills the cache, so the next click is instant.
150
151def test_a_play_request_does_not_queue_behind_a_resolve_in_flight(monkeypatch):
152 monkeypatch.setattr(addon, "VIDCORE_PLAY_DEADLINE", 0.2)
153 lock = threading.Lock()
154 monkeypatch.setattr(addon, "_vidcore_locks", {KEY: lock})
155 monkeypatch.setattr(addon, "_resolve_vidcore_locked",
156 lambda *a, **k: pytest.fail("started a second resolve"))
157 lock.acquire() # a prefetch is mid-resolve
158 started = time.time()
159 got = addon._resolve_vidcore(KIND, IDENT, S, E,
160 wait=addon.VIDCORE_PLAY_DEADLINE)
161 waited = time.time() - started
162 lock.release()
163 assert got is None
164 assert waited < 2, f"waited {waited:.1f}s on a lock it should have abandoned"
165
166
167def test_a_play_request_takes_a_cache_the_holder_filled(monkeypatch):
168 """Giving up must still prefer an answer over none."""
169 lock = threading.Lock()
170 monkeypatch.setattr(addon, "_vidcore_locks", {KEY: lock})
171 lock.acquire()
172 addon._vidcore_cache[KEY] = (time.time() + 600, "https://cdn/master.m3u8")
173 got = addon._resolve_vidcore(KIND, IDENT, S, E, wait=0.2)
174 lock.release()
175 assert got == "https://cdn/master.m3u8"
176
177
178def test_a_background_resolve_still_waits_for_the_lock(monkeypatch):
179 """Only the play path has a deadline. An ingest has all the time it needs."""
180 lock = threading.Lock()
181 monkeypatch.setattr(addon, "_vidcore_locks", {KEY: lock})
182 monkeypatch.setattr(addon, "_resolve_vidcore_locked",
183 lambda *a, **k: "https://cdn/late.m3u8")
184 lock.acquire()
185 out = []
186 t = threading.Thread(target=lambda: out.append(
187 addon._resolve_vidcore(KIND, IDENT, S, E)))
188 t.start()
189 t.join(timeout=0.5)
190 assert out == [], "a background resolve gave up on the lock"
191 lock.release()
192 t.join(timeout=5)
193 assert out == ["https://cdn/late.m3u8"]
194
195
196@pytest.mark.parametrize("path,args", [
197 ("/vidcore/movie/tmdb:1081003.m3u8?h=1080", ("movie", "tmdb:1081003")),
198 ("/mp4/movie/tmdb:1081003.mp4?h=1080", ("movie", "tmdb:1081003", 0, 0)),
199])
200def test_the_play_routes_pass_the_deadline(monkeypatch, path, args):
201 seen = {}
202
203 def resolve(*a, **k):
204 seen.update(kwargs=k, args=a)
205 return None # 404 is fine; we only check the call
206 monkeypatch.setattr(addon, "_resolve_vidcore", resolve)
207 monkeypatch.setattr(addon, "_playable_path", lambda *a, **k: None)
208 monkeypatch.setattr(addon, "_ensure_ingest", lambda *a, **k: None)
209 addon.app.test_client().get(path)
210 assert seen.get("kwargs", {}).get("wait") == addon.VIDCORE_PLAY_DEADLINE
211
212
138213def test_prefetch_failure_never_breaks_the_stream_response(monkeypatch):
139214 """/stream must answer even if kicking off the prefetch throws."""
140215 def boom(*a, **k):