OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 24935ba

Fetch a whole season, or as much of it as has aired (0.15.0)
Not new machinery. _queue_next_episode has fetched the next episode after every play since the library existed -- "so a series becomes a local library" -- and a season is that lookahead raised. Same ingest queue, same semaphore, same resume-on-boot, so a restart mid-season already picks up where it left off. Released is decided by air date, never by episode count. TMDB lists a running season's unaired episodes too, and asking vidcore for one costs a full browser resolve to learn nothing. An episode carrying no air_date at all counts as unreleased: that field is how the answer is expressed, and guessing the other way queues work that cannot succeed. Episodes run ONE AT A TIME, through _run_job -- which is _start_mp4_job's thread body, lifted out so a season can drive it directly. Spawning a job per episode would leave everything past the second blocked on the remux semaphore for up to MP4_REMUX_TIMEOUT, and a long season would start reporting "remux slots busy" for episodes nothing was wrong with. Running through the same semaphore is also what keeps a season queuing politely behind someone pressing play. The button sits on the audit page next to Re-download, POST only for the same reason, and refuses to start a second run of a season already going. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24935ba2b2006545fc9caf67bf8b082b18ba1ed8
by plankatron <fleeky@gmail.com> · 2026-08-31T14:47:12.000Z

Changed files (2) +281 -13

addon.py +116 -13
……@@ -596,7 +596,7 @@ TV_GENRES = [
596596
597597MANIFEST = {
598598 "id": "org.tabertron.vsembed",
599 "version": "0.14.0",
599 "version": "0.15.0",
600600 "name": "Tabertron VsEmbed",
601601 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
602602 "types": ["movie", "series"],
……@@ -1742,21 +1742,100 @@ def _start_mp4_job(kind: str, ident: str, season: int, episode: int,
17421742 # can pick the job up again — a key on its own cannot be re-run.
17431743 _set_job_state(key, "running", args=(kind, ident, season, episode, height))
17441744
1745 threading.Thread(target=_run_job, name=f"mp4-{key}",
1746 args=(kind, ident, season, episode, height, force),
1747 daemon=True).start()
1748 return "running"
1749
1750
1751def _run_job(kind: str, ident: str, season: int, episode: int,
1752 height: int, force: bool = False) -> str:
1753 """One ingest, through the remux semaphore, with its state recorded.
1754
1755 Split out of the thread so a season can drive episodes SEQUENTIALLY through
1756 the same gate. Spawning a job per episode instead would leave every one past
1757 the second blocked on the semaphore for up to MP4_REMUX_TIMEOUT, and a long
1758 season would start reporting "remux slots busy" for episodes nothing was
1759 actually wrong with.
1760 """
1761 key = _mp4_key(kind, ident, season, episode, height)
1762 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
1763 try:
1764 state = (_run_ingest(kind, ident, season, episode, height, force)
1765 if acquired else "failed: remux slots busy")
1766 except Exception as exc: # never leave a job "running"
1767 log.warning("ingest crashed for %s: %s", key, exc)
1768 state = f"failed: {exc}"
1769 finally:
1770 if acquired:
1771 _mp4_sem.release()
1772 with _mp4_jobs_guard:
1773 _set_job_state(key, state)
1774 return state
1775
1776
1777def _released_episodes(tmdb_id: str, season: int) -> list[int]:
1778 """Episode numbers of this season that have actually aired.
1779
1780 By air date, never by episode count: TMDB lists a running season's unaired
1781 episodes too, and asking vidcore for one costs a full browser resolve to
1782 learn nothing. An episode with no air_date at all counts as unreleased --
1783 that field is how "released" is expressed here, and guessing the other way
1784 queues work that cannot succeed.
1785 """
1786 try:
1787 data = _tmdb_get(f"tv/{tmdb_id}/season/{season}")
1788 except Exception as exc:
1789 log.warning("season listing failed for %s S%s: %s", tmdb_id, season, exc)
1790 return []
1791 today = time.strftime("%Y-%m-%d")
1792 out = []
1793 for ep in data.get("episodes") or []:
1794 air = (ep.get("air_date") or "").strip()
1795 num = ep.get("episode_number")
1796 if air and air <= today and isinstance(num, int):
1797 out.append(num)
1798 return sorted(out)
1799
1800
1801_season_jobs: dict[str, str] = {}
1802_season_guard = threading.Lock()
1803
1804
1805def _start_season_job(ident: str, season: int, height: int,
1806 force: bool = False) -> str:
1807 """Fetch every released episode of a season, one at a time.
1808
1809 This is _queue_next_episode's lookahead of 1 raised to a whole season -- the
1810 same machinery, the same persistent queue, the same semaphore. Nothing new
1811 holds the work: a restart mid-season resumes from ingest_queue.json exactly
1812 as it already did for a single episode.
1813 """
1814 key = f"season:{ident}:{season}:{height}"
1815 with _season_guard:
1816 if _season_jobs.get(key) == "running":
1817 return "running"
1818 _season_jobs[key] = "running"
1819
17451820 def run() -> None:
1746 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
1821 done = 0
17471822 try:
1748 state = (_run_ingest(kind, ident, season, episode, height, force)
1749 if acquired else "failed: remux slots busy")
1750 except Exception as exc: # never leave a job "running"
1751 log.warning("ingest crashed for %s: %s", key, exc)
1752 state = f"failed: {exc}"
1823 meta = _library_meta("series", ident, season, 1)
1824 episodes = _released_episodes(meta["tmdb_id"], season) if meta else []
1825 log.info("season %s S%02d: %d released episode(s)",
1826 ident, season, len(episodes))
1827 for episode in episodes:
1828 if not force and _library_has("series", ident, season, episode):
1829 continue
1830 _run_job("series", ident, season, episode, height, force)
1831 done += 1
1832 except Exception as exc:
1833 log.warning("season job crashed for %s: %s", key, exc)
17531834 finally:
1754 if acquired:
1755 _mp4_sem.release()
1756 with _mp4_jobs_guard:
1757 _set_job_state(key, state)
1835 with _season_guard:
1836 _season_jobs[key] = f"done ({done})"
17581837
1759 threading.Thread(target=run, name=f"mp4-{key}", daemon=True).start()
1838 threading.Thread(target=run, name=f"season-{key}", daemon=True).start()
17601839 return "running"
17611840
17621841
……@@ -2401,7 +2480,14 @@ def _audit_html(rows: list[dict]) -> str:
24012480 f"<input type=hidden name=episode value='{r['episode']}'>"
24022481 f"<input type=hidden name=height value='{r['height']}'>"
24032482 "<button name=force value=1>Re-download</button>"
2404 "</form></td></tr>")
2483 "</form>"
2484 + ("<form method=post action=/library/season>"
2485 f"<input type=hidden name=ident value='{html.escape(r['ident'])}'>"
2486 f"<input type=hidden name=season value='{r['season']}'>"
2487 f"<input type=hidden name=height value='{r['height']}'>"
2488 f"<button>Get season {r['season']}</button></form>"
2489 if r["kind"] == "series" else "")
2490 + "</td></tr>")
24052491 body.append("</table>")
24062492 return (f"<!doctype html><meta charset=utf-8>"
24072493 f"<meta name=viewport content='width=device-width,initial-scale=1'>"
……@@ -2442,6 +2528,23 @@ def library_reingest():
24422528 return redirect("/library", code=303)
24432529
24442530
2531@app.route("/library/season", methods=["POST"])
2532def library_season():
2533 """POST only, for the same reason as the re-ingest route."""
2534 ident = request.form.get("ident") or ""
2535 if not ident:
2536 abort(400)
2537 try:
2538 season = int(request.form.get("season") or 0)
2539 height = int(request.form.get("height") or VIDCORE_MAX_HEIGHT)
2540 except ValueError:
2541 abort(400)
2542 state = _start_season_job(ident, season, height)
2543 log.info("library: season fetch requested for %s S%02d -> %s",
2544 ident, season, state)
2545 return redirect("/library", code=303)
2546
2547
24452548@app.route("/")
24462549def index():
24472550 return (
test_season_ingest.py +165 -0
……@@ -0,0 +1,165 @@
1"""Fetching a whole season -- or as much of it as has actually aired.
2
3This is not new machinery. _queue_next_episode has fetched the NEXT episode
4after each play since the library existed, "so a series becomes a local
5library"; a season is that lookahead raised, running through the same ingest
6queue, the same semaphore and the same resume-on-boot path.
7
8Run: .venv/bin/python3 -m pytest test_season_ingest.py -q
9"""
10
11import os
12import time
13
14import pytest
15
16import addon
17
18
19@pytest.fixture(autouse=True)
20def isolated(tmp_path, monkeypatch):
21 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path))
22 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE",
23 os.path.join(str(tmp_path), "queue.json"))
24 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE",
25 os.path.join(str(tmp_path), "verified.json"))
26 monkeypatch.setattr(addon, "_library_meta_cache", {})
27 monkeypatch.setattr(addon, "_season_jobs", {})
28 monkeypatch.setattr(addon, "_mp4_jobs", {})
29 yield
30
31
32def _settled(timeout=5.0):
33 """Block until the season worker has finished.
34
35 Polls the job state rather than joining by thread name: the worker may not
36 have been scheduled yet when the assertion runs, and a name lookup that
37 finds nothing would pass by accident.
38 """
39 deadline = time.time() + timeout
40 while time.time() < deadline:
41 states = list(addon._season_jobs.values())
42 if states and all(not s.startswith("running") for s in states):
43 return
44 time.sleep(0.01)
45 raise AssertionError(f"season job did not settle: {addon._season_jobs}")
46
47
48def _season(*episodes):
49 """episodes: (number, air_date) pairs."""
50 return {"episodes": [{"episode_number": n, "air_date": d}
51 for n, d in episodes]}
52
53
54# --- what counts as released ------------------------------------------------
55
56def test_only_aired_episodes_are_listed(monkeypatch):
57 """TMDB lists a running season's unaired episodes too. Asking vidcore for
58 one costs a full browser resolve to learn nothing."""
59 monkeypatch.setattr(addon, "_tmdb_get", lambda p, params=None: _season(
60 (1, "2026-01-01"), (2, "2026-01-08"), (3, "2099-01-01")))
61 assert addon._released_episodes("1", 4) == [1, 2]
62
63
64def test_an_episode_with_no_air_date_is_not_released(monkeypatch):
65 """The field is how "released" is expressed. Guessing the other way queues
66 work that cannot succeed."""
67 monkeypatch.setattr(addon, "_tmdb_get", lambda p, params=None: _season(
68 (1, "2026-01-01"), (2, ""), (3, None)))
69 assert addon._released_episodes("1", 4) == [1]
70
71
72def test_episodes_come_back_in_order(monkeypatch):
73 monkeypatch.setattr(addon, "_tmdb_get", lambda p, params=None: _season(
74 (3, "2026-01-01"), (1, "2026-01-01"), (2, "2026-01-01")))
75 assert addon._released_episodes("1", 4) == [1, 2, 3]
76
77
78def test_a_tmdb_failure_yields_no_episodes_rather_than_raising(monkeypatch):
79 def boom(path, params=None):
80 raise RuntimeError("tmdb down")
81 monkeypatch.setattr(addon, "_tmdb_get", boom)
82 assert addon._released_episodes("1", 4) == []
83
84
85# --- the season job ---------------------------------------------------------
86
87@pytest.fixture
88def season(monkeypatch):
89 """A four-episode season, three aired, with the ingest itself stubbed."""
90 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: {
91 "tv/1": {"name": "Show", "first_air_date": "2020-01-01"},
92 "tv/1/season/4/episode/1": {"name": "One", "runtime": 50},
93 "tv/1/season/4": _season((1, "2026-01-01"), (2, "2026-01-02"),
94 (3, "2026-01-03"), (4, "2099-01-01")),
95 }[path])
96 ran = []
97 monkeypatch.setattr(addon, "_run_job",
98 lambda *a, **k: (ran.append(a), "done")[1])
99 return ran
100
101
102def test_a_season_fetches_every_released_episode(season):
103 addon._start_season_job("tmdb:1", 4, 1080)
104 _settled()
105 assert [a[3] for a in season] == [1, 2, 3]
106
107
108def test_the_unaired_episode_is_not_fetched(season):
109 addon._start_season_job("tmdb:1", 4, 1080)
110 _settled()
111 assert 4 not in [a[3] for a in season]
112
113
114def test_episodes_already_held_are_skipped(season, monkeypatch):
115 monkeypatch.setattr(addon, "_library_has",
116 lambda k, i, s, e: e == 2)
117 addon._start_season_job("tmdb:1", 4, 1080)
118 _settled()
119 assert [a[3] for a in season] == [1, 3]
120
121
122def test_a_forced_season_refetches_even_what_is_held(season, monkeypatch):
123 monkeypatch.setattr(addon, "_library_has", lambda k, i, s, e: True)
124 addon._start_season_job("tmdb:1", 4, 1080, force=True)
125 _settled()
126 assert [a[3] for a in season] == [1, 2, 3]
127
128
129def test_the_same_season_is_not_started_twice(season, monkeypatch):
130 """Pressing the button twice must not double every remux."""
131 monkeypatch.setattr(addon, "_season_jobs", {"season:tmdb:1:4:1080": "running"})
132 assert addon._start_season_job("tmdb:1", 4, 1080) == "running"
133 assert season == []
134
135
136def test_episodes_run_one_at_a_time(season):
137 """Through _run_job, not _start_mp4_job: a job per episode would leave
138 everything past the second blocked on the semaphore for up to
139 MP4_REMUX_TIMEOUT and reporting "remux slots busy"."""
140 addon._start_season_job("tmdb:1", 4, 1080)
141 _settled()
142 assert len(season) == 3 # sequential calls, not spawned threads
143
144
145# --- the route --------------------------------------------------------------
146
147def test_season_is_not_reachable_by_a_get():
148 assert addon.app.test_client().get("/library/season").status_code == 405
149
150
151def test_season_route_starts_the_job(monkeypatch):
152 calls = []
153 monkeypatch.setattr(addon, "_start_season_job",
154 lambda *a, **k: (calls.append(a), "running")[1])
155 r = addon.app.test_client().post(
156 "/library/season",
157 data={"ident": "tmdb:1", "season": "4", "height": "1080"})
158 assert r.status_code == 303 and calls[0] == ("tmdb:1", 4, 1080)
159
160
161def test_season_route_rejects_a_missing_ident():
162 r = addon.app.test_client().post("/library/season", data={"season": "4"})
163 assert r.status_code == 400
164
165