| … | … | @@ -596,7 +596,7 @@ TV_GENRES = [ |
| 596 | 596 | |
| 597 | 597 | MANIFEST = { |
| 598 | 598 | "id": "org.tabertron.vsembed", |
| 599 | "version": "0.14.0", | |
| 599 | "version": "0.15.0", | |
| 600 | 600 | "name": "Tabertron VsEmbed", |
| 601 | 601 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 602 | 602 | "types": ["movie", "series"], |
| … | … | @@ -1742,21 +1742,100 @@ def _start_mp4_job(kind: str, ident: str, season: int, episode: int, |
| 1742 | 1742 | # can pick the job up again — a key on its own cannot be re-run. |
| 1743 | 1743 | _set_job_state(key, "running", args=(kind, ident, season, episode, height)) |
| 1744 | 1744 | |
| 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 | ||
| 1751 | def _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 | ||
| 1777 | def _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 | ||
| 1805 | def _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 | ||
| 1745 | 1820 | def run() -> None: |
| 1746 | acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT) | |
| 1821 | done = 0 | |
| 1747 | 1822 | 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) | |
| 1753 | 1834 | 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})" | |
| 1758 | 1837 | |
| 1759 | threading.Thread(target=run, name=f"mp4-{key}", daemon=True).start() | |
| 1838 | threading.Thread(target=run, name=f"season-{key}", daemon=True).start() | |
| 1760 | 1839 | return "running" |
| 1761 | 1840 | |
| 1762 | 1841 | |
| … | … | @@ -2401,7 +2480,14 @@ def _audit_html(rows: list[dict]) -> str: |
| 2401 | 2480 | f"<input type=hidden name=episode value='{r['episode']}'>" |
| 2402 | 2481 | f"<input type=hidden name=height value='{r['height']}'>" |
| 2403 | 2482 | "<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>") | |
| 2405 | 2491 | body.append("</table>") |
| 2406 | 2492 | return (f"<!doctype html><meta charset=utf-8>" |
| 2407 | 2493 | f"<meta name=viewport content='width=device-width,initial-scale=1'>" |
| … | … | @@ -2442,6 +2528,23 @@ def library_reingest(): |
| 2442 | 2528 | return redirect("/library", code=303) |
| 2443 | 2529 | |
| 2444 | 2530 | |
| 2531 | @app.route("/library/season", methods=["POST"]) | |
| 2532 | def 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 | ||
| 2445 | 2548 | @app.route("/") |
| 2446 | 2549 | def index(): |
| 2447 | 2550 | return ( |
| … | … | @@ -0,0 +1,165 @@ |
| 1 | """Fetching a whole season -- or as much of it as has actually aired. | |
| 2 | ||
| 3 | This is not new machinery. _queue_next_episode has fetched the NEXT episode | |
| 4 | after each play since the library existed, "so a series becomes a local | |
| 5 | library"; a season is that lookahead raised, running through the same ingest | |
| 6 | queue, the same semaphore and the same resume-on-boot path. | |
| 7 | ||
| 8 | Run: .venv/bin/python3 -m pytest test_season_ingest.py -q | |
| 9 | """ | |
| 10 | ||
| 11 | import os | |
| 12 | import time | |
| 13 | ||
| 14 | import pytest | |
| 15 | ||
| 16 | import addon | |
| 17 | ||
| 18 | ||
| 19 | @pytest.fixture(autouse=True) | |
| 20 | def 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 | ||
| 32 | def _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 | ||
| 48 | def _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 | ||
| 56 | def 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 | ||
| 64 | def 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 | ||
| 72 | def 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 | ||
| 78 | def 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 | |
| 88 | def 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 | ||
| 102 | def 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 | ||
| 108 | def 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 | ||
| 114 | def 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 | ||
| 122 | def 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 | ||
| 129 | def 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 | ||
| 136 | def 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 | ||
| 147 | def test_season_is_not_reachable_by_a_get(): | |
| 148 | assert addon.app.test_client().get("/library/season").status_code == 405 | |
| 149 | ||
| 150 | ||
| 151 | def 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 | ||
| 161 | def 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 |