OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 60cd27a

Roll forward: watching an episode fetches the next (0.11.0)
Turns a series into a local library. Playing S02E07 queues S02E08, from both the vidcore route and /mp4/ -- the latter matters because once a series is in the library it plays from disk, and rolling would otherwise stall the moment it started working. This is the only trigger that fetches something the user did not choose, so it has two independent bounds: - TMDB says whether a next episode exists at all, across a season boundary and at the end of a series. Any lookup failure stops rather than guessing an episode number, because stopping is always safe. - The reputation store says whether the source actually has it. The reputation threshold here is ONE strike, deliberately lower than the two _rep_is_known_miss uses to hide a row. Hiding a row costs the user the title outright if we are wrong; declining a speculative prefetch costs nothing, since the episode stays playable and playing it resolves normally and re-files its own reputation. So one strike is enough to not go looking, but not enough to hide. Bounded to one ahead -- watching an episode must not enqueue a season. 11 new tests, 117 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
60cd27a36681ed2247981ce059ca6fa904381f50
by plankatron <fleeky@gmail.com> · 2026-08-11T14:57:45.000Z

Changed files (2) +157 -3

addon.py +62 -3
……@@ -533,7 +533,7 @@ TV_GENRES = [
533533
534534MANIFEST = {
535535 "id": "org.tabertron.vsembed",
536 "version": "0.10.0",
536 "version": "0.11.0",
537537 "name": "Tabertron VsEmbed",
538538 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
539539 "types": ["movie", "series"],
……@@ -1041,8 +1041,10 @@ def vidcore_series(ident: str):
10411041 url = _resolve_vidcore("series", m.group(1), int(m.group(2)), int(m.group(3)))
10421042 if not url:
10431043 abort(404)
1044 _ensure_ingest("series", m.group(1), int(m.group(2)), int(m.group(3)),
1045 height=_requested_height())
1044 season, episode = int(m.group(2)), int(m.group(3))
1045 height = _requested_height()
1046 _ensure_ingest("series", m.group(1), season, episode, height=height)
1047 _queue_next_episode(m.group(1), season, episode, height)
10461048 return _serve_vidcore(url)
10471049
10481050
……@@ -1384,8 +1386,65 @@ def _ensure_ingest(kind: str, ident: str, season: int = 0, episode: int = 0,
13841386 _start_mp4_job(kind, ident, season, episode, height)
13851387
13861388
1389def _next_episode(tmdb_id: str, season: int,
1390 episode: int) -> Optional[tuple[int, int]]:
1391 """What follows this episode per TMDB, or None if nothing does.
1392
1393 TMDB is the bound on rolling forward: it knows where a season ends and where
1394 the series does. Any failure returns None — stopping is always safe, whereas
1395 guessing an episode number fetches something that does not exist.
1396 """
1397 try:
1398 cur = _tmdb_get(f"tv/{tmdb_id}/season/{season}")
1399 numbers = {e.get("episode_number") for e in cur.get("episodes", [])}
1400 if episode + 1 in numbers:
1401 return (season, episode + 1)
1402 nxt = _tmdb_get(f"tv/{tmdb_id}/season/{season + 1}")
1403 following = sorted(n for n in (e.get("episode_number")
1404 for e in nxt.get("episodes", [])) if n)
1405 if following:
1406 return (season + 1, following[0])
1407 except Exception as exc:
1408 log.info("rolling: no next episode after S%02dE%02d (%s)",
1409 season, episode, exc)
1410 return None
1411
1412
1413def _queue_next_episode(ident: str, season: int, episode: int,
1414 height: int) -> None:
1415 """Fetch the episode after this one, so a series becomes a local library.
1416
1417 This is the only trigger that fetches something the user did not choose
1418 directly, so it stops on the FIRST sign of doubt, at a deliberately lower
1419 threshold than _rep_is_known_miss uses for hiding a row.
1420
1421 The asymmetry is intentional. Hiding a row costs the user the title outright
1422 if we are wrong, so that needs two consecutive strikes. Declining a
1423 speculative prefetch costs nothing — the episode stays fully playable, and
1424 playing it resolves normally and re-files its own reputation. So one strike
1425 is enough to not go looking.
1426 """
1427 meta = _library_meta("series", ident, season, episode)
1428 if not meta:
1429 return
1430 nxt = _next_episode(meta["tmdb_id"], season, episode)
1431 if not nxt:
1432 return
1433 ns, ne = nxt
1434 rep = _vidcore_rep.get(_rep_key("series", ident, ns, ne)) or {}
1435 if rep.get("status") == "miss" and int(rep.get("strikes", 0)) >= 1:
1436 log.info("rolling: S%02dE%02d previously absent — not prefetching", ns, ne)
1437 return
1438 _ensure_ingest("series", ident, ns, ne, height)
1439
1440
13871441def _serve_mp4(kind: str, ident: str, season: int, episode: int):
13881442 height = _requested_height()
1443 if kind == "series":
1444 # Also from here, not just the vidcore route: once a series is in the
1445 # library it is played from disk, and rolling forward would otherwise
1446 # stall the moment it started working.
1447 _queue_next_episode(ident, season, episode, height)
13891448 path = _playable_path(kind, ident, season, episode, height)
13901449 if path:
13911450 # conditional=True is what answers Range requests, and Range is what
test_library_ingest.py +95 -0
……@@ -517,6 +517,101 @@ def test_an_unidentifiable_title_offers_no_keep_row(triggered):
517517 assert out == []
518518
519519
520# --- next-episode queueing --------------------------------------------------
521# The only trigger that fetches something the user did NOT directly choose, so
522# it is the one that must know when to stop. Two independent bounds: TMDB says
523# whether a next episode exists at all, and the reputation store says whether
524# the source actually has it.
525
526@pytest.fixture
527def rolling(monkeypatch, tmdb, tmp_path):
528 tmdb.responses["tv/1"] = {"name": "Show", "first_air_date": "2020-01-01"}
529 for e in (1, 2, 3):
530 tmdb.responses[f"tv/1/season/1/episode/{e}"] = {"name": f"E{e}"}
531 tmdb.responses["tv/1/season/1"] = {
532 "episodes": [{"episode_number": 1}, {"episode_number": 2}]}
533 tmdb.responses["tv/1/season/2"] = {"episodes": [{"episode_number": 1}]}
534 tmdb.responses["tv/1/season/3"] = {"episodes": []}
535 tmdb.responses["tv/1/season/2/episode/1"] = {"name": "S2E1"}
536 monkeypatch.setattr(addon, "MP4_DIR", os.path.join(str(tmp_path), "cache"))
537 os.makedirs(addon.MP4_DIR, exist_ok=True)
538 monkeypatch.setattr(addon, "_prefetch_vidcore", lambda *a, **k: None)
539 monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/m.m3u8")
540 monkeypatch.setattr(addon, "_cap_variant", lambda url, h: url)
541 monkeypatch.setattr(addon, "_vidcore_rep", {})
542 queued = []
543 monkeypatch.setattr(addon, "_start_mp4_job",
544 lambda *a, **k: queued.append(a) or "running")
545 return queued
546
547
548def test_the_next_episode_in_a_season_is_found(rolling):
549 assert addon._next_episode("1", 1, 1) == (1, 2)
550
551
552def test_the_end_of_a_season_rolls_into_the_next(rolling):
553 assert addon._next_episode("1", 1, 2) == (2, 1)
554
555
556def test_the_end_of_the_series_stops(rolling):
557 """Season 3 exists in TMDB but is empty, and there is no season 4."""
558 assert addon._next_episode("1", 2, 1) is None
559
560
561def test_playing_an_episode_queues_the_next_one(rolling):
562 addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720")
563 assert (("series", "tmdb:1", 1, 2, 720)) in [tuple(q) for q in rolling]
564
565
566def test_playing_a_library_copy_also_rolls_forward(rolling):
567 """Otherwise the rolling library stalls the moment you start watching from
568 it -- the local file is served without ever touching the vidcore route."""
569 dest = addon._library_path("series", "Show", "2020", "1",
570 season=1, episode=1, episode_title="E1")
571 os.makedirs(os.path.dirname(dest), exist_ok=True)
572 open(dest, "wb").write(b"x" * 10)
573 addon.app.test_client().get("/mp4/series/tmdb:1:1:1.mp4?h=720")
574 assert (("series", "tmdb:1", 1, 2, 720)) in [tuple(q) for q in rolling]
575
576
577def test_a_next_episode_the_source_lacks_is_not_queued(rolling):
578 """A known miss is a positive finding that vidcore does not have it. Queueing
579 it would spend a browser launch to rediscover that on every play."""
580 addon._vidcore_rep[addon._rep_key("series", "tmdb:1", 1, 2)] = {
581 "status": "miss", "ts": addon.time.time(), "strikes": 2}
582 addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720")
583 assert [q for q in rolling if q[3] == 2] == []
584
585
586def test_an_inconclusive_next_episode_is_not_queued(rolling):
587 """Inconclusive must NOT harden into 'go and fetch it' either -- one strike
588 is not a finding. It stays queueable only once it is genuinely unknown, not
589 while a previous attempt was ambiguous."""
590 addon._vidcore_rep[addon._rep_key("series", "tmdb:1", 1, 2)] = {
591 "status": "miss", "ts": addon.time.time(), "strikes": 1}
592 addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720")
593 nxt = [q for q in rolling if q[3] == 2]
594 assert nxt == [], "a pending strike should not trigger a speculative fetch"
595
596
597def test_queueing_does_not_chain_beyond_one(rolling):
598 """Watching one episode must not enqueue a whole season."""
599 addon.app.test_client().get("/vidcore/series/tmdb:1:1:1.m3u8?h=720")
600 assert len([q for q in rolling if q[1] == "tmdb:1"]) <= 2
601
602
603def test_a_movie_never_queues_anything(rolling, tmdb):
604 tmdb.responses["movie/5"] = {"title": "Film", "release_date": "2020-01-01"}
605 addon.app.test_client().get("/vidcore/movie/tmdb:5.m3u8?h=720")
606 assert all(q[0] != "series" for q in rolling)
607
608
609def test_a_tmdb_failure_stops_rolling_rather_than_guessing(rolling, monkeypatch):
610 monkeypatch.setattr(addon, "_tmdb_get",
611 lambda *a, **k: (_ for _ in ()).throw(RuntimeError("down")))
612 assert addon._next_episode("1", 1, 1) is None
613
614
520615def test_ingest_resolves_at_run_time_not_at_queue_time(ingest, monkeypatch):
521616 """Variant URLs are signed and expire in ~4h. A job that resolved when it
522617 was queued would fetch a dead link once the queue backs up."""