| … | … | @@ -596,7 +596,7 @@ TV_GENRES = [ |
| 596 | 596 | |
| 597 | 597 | MANIFEST = { |
| 598 | 598 | "id": "org.tabertron.vsembed", |
| 599 | "version": "0.15.0", | |
| 599 | "version": "0.16.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"], |
| … | … | @@ -2064,6 +2064,44 @@ def _append_mp4_row(out: list, kind: str, ident: Optional[str], |
| 2064 | 2064 | else "saving to library…", VIDCORE_MAX_HEIGHT) |
| 2065 | 2065 | |
| 2066 | 2066 | |
| 2067 | def _append_action_rows(out: list, kind: str, ident: Optional[str], | |
| 2068 | season: int = 0, episode: int = 0) -> None: | |
| 2069 | """Rows that DO something, for the judgments no measurement can make. | |
| 2070 | ||
| 2071 | The completeness gate catches a copy that is SHORT. It cannot catch the | |
| 2072 | wrong episode, the wrong season, or audio nobody can listen to -- those need | |
| 2073 | eyes, and the eyes are in front of the TV, not the audit page. So the escape | |
| 2074 | hatch has to be here, in the list Jacktook already puts up before playing, | |
| 2075 | which is the one surface Kore can drive. | |
| 2076 | ||
| 2077 | Listed LAST, after every row anyone actually came for: a Kodi source list is | |
| 2078 | navigated by arrow key. | |
| 2079 | """ | |
| 2080 | if not ident or not _library_meta(kind, ident, season, episode): | |
| 2081 | return # cannot be named, so the row would be a lie | |
| 2082 | if kind == "series": | |
| 2083 | refetch = f"/refetch/series/{ident}:{season}:{episode}.m3u8" | |
| 2084 | tag = f"S{season:02d}E{episode:02d} " | |
| 2085 | else: | |
| 2086 | refetch = f"/refetch/movie/{ident}.m3u8" | |
| 2087 | tag = "" | |
| 2088 | ||
| 2089 | def row(path: str, quality: str, title: str) -> None: | |
| 2090 | out.append(_stream_dict(Stream( | |
| 2091 | url=f"http://{request.host}{path}?h={VIDCORE_MAX_HEIGHT}", | |
| 2092 | quality=quality, name="local", referer="https://vidcore.net/", | |
| 2093 | title_extra=title))) | |
| 2094 | ||
| 2095 | # Only when a copy actually exists. With none, _append_mp4_row's "play & | |
| 2096 | # keep" row already fetches one, and this would be offering to redo nothing. | |
| 2097 | if _playable_path(kind, ident, season, episode, VIDCORE_MAX_HEIGHT): | |
| 2098 | row(refetch, "re-download", | |
| 2099 | f"{tag}wrong copy? re-download\nplays live meanwhile") | |
| 2100 | if kind == "series": | |
| 2101 | row(f"/season/series/{ident}:{season}:{episode}.m3u8", "season", | |
| 2102 | f"get season {season} — aired episodes\nplays this one meanwhile") | |
| 2103 | ||
| 2104 | ||
| 2067 | 2105 | def _append_vidcore_row(out: list, kind: str, ident: Optional[str], |
| 2068 | 2106 | season: int = 0, episode: int = 0) -> None: |
| 2069 | 2107 | """Additive lazy vidcore rows — one per quality, hidden for known misses. |
| … | … | @@ -2110,6 +2148,61 @@ def _append_vidcore_row(out: list, kind: str, ident: Optional[str], |
| 2110 | 2148 | _vidcore_row(request.host, kind, ident, season, episode, height=height))) |
| 2111 | 2149 | |
| 2112 | 2150 | |
| 2151 | # These MUST be GET. A Stremio row is a URL the player opens, and Kodi opens it | |
| 2152 | # with GET -- the exact opposite of the POST-only rule on /library/reingest. | |
| 2153 | # | |
| 2154 | # It is safe here for a specific reason rather than by luck. Kodi issues | |
| 2155 | # GET + HEAD + GET for a single stream URL (measured 2026-08-10, when a fixed | |
| 2156 | # Chromium profile turned that into three resolvers fighting over one browser | |
| 2157 | # session), so anything these start fires up to three times. Both job starters | |
| 2158 | # already refuse a duplicate: _start_mp4_job returns early while its key is | |
| 2159 | # "running", _start_season_job while that season is. That idempotency is | |
| 2160 | # LOAD-BEARING here, not incidental -- remove it and one click becomes three | |
| 2161 | # remuxes of the same episode. | |
| 2162 | # | |
| 2163 | # Each answers with the live upstream, which is _serve_mp4's established idiom: | |
| 2164 | # a row has to be playable the instant it is clicked, and a 503 would surface as | |
| 2165 | # a playback failure while the work went on invisibly. | |
| 2166 | ||
| 2167 | def _serve_action(kind: str, ident: str, season: int, episode: int, | |
| 2168 | whole_season: bool = False): | |
| 2169 | height = _requested_height() | |
| 2170 | if whole_season: | |
| 2171 | _start_season_job(ident, season, height) | |
| 2172 | else: | |
| 2173 | _start_mp4_job(kind, ident, season, episode, height, force=True) | |
| 2174 | url = _resolve_vidcore(kind, ident, season, episode, | |
| 2175 | wait=VIDCORE_PLAY_DEADLINE) | |
| 2176 | if not url: | |
| 2177 | abort(404) | |
| 2178 | return _serve_vidcore(url) | |
| 2179 | ||
| 2180 | ||
| 2181 | @app.route("/refetch/movie/<path:ident>.m3u8") | |
| 2182 | def refetch_movie(ident: str): | |
| 2183 | if not re.fullmatch(VIDCORE_IDENT, ident): | |
| 2184 | abort(404) | |
| 2185 | return _serve_action("movie", ident, 0, 0) | |
| 2186 | ||
| 2187 | ||
| 2188 | @app.route("/refetch/series/<path:ident>.m3u8") | |
| 2189 | def refetch_series(ident: str): | |
| 2190 | m = re.fullmatch(rf"({VIDCORE_IDENT}):(\d+):(\d+)", ident) | |
| 2191 | if not m: | |
| 2192 | abort(404) | |
| 2193 | return _serve_action("series", m.group(1), int(m.group(2)), int(m.group(3))) | |
| 2194 | ||
| 2195 | ||
| 2196 | @app.route("/season/series/<path:ident>.m3u8") | |
| 2197 | def season_series_row(ident: str): | |
| 2198 | """Queue the season, and play the episode it was asked from meanwhile.""" | |
| 2199 | m = re.fullmatch(rf"({VIDCORE_IDENT}):(\d+):(\d+)", ident) | |
| 2200 | if not m: | |
| 2201 | abort(404) | |
| 2202 | return _serve_action("series", m.group(1), int(m.group(2)), | |
| 2203 | int(m.group(3)), whole_season=True) | |
| 2204 | ||
| 2205 | ||
| 2113 | 2206 | @app.route("/stream/movie/<path:ident>.json") |
| 2114 | 2207 | def stream_movie(ident: str): |
| 2115 | 2208 | ident = ident.strip() |
| … | … | @@ -2118,6 +2211,7 @@ def stream_movie(ident: str): |
| 2118 | 2211 | out: list = [] |
| 2119 | 2212 | _append_mp4_row(out, "movie", f"tmdb:{fxl.group(1)}") |
| 2120 | 2213 | _append_vidcore_row(out, "movie", f"tmdb:{fxl.group(1)}") |
| 2214 | _append_action_rows(out, "movie", f"tmdb:{fxl.group(1)}") | |
| 2121 | 2215 | return jsonify({"streams": out}) |
| 2122 | 2216 | fmv = re.fullmatch(r"fmv:(\d+):([a-z0-9\-]+)", ident) |
| 2123 | 2217 | if fmv: |
| … | … | @@ -2150,6 +2244,7 @@ def stream_series(ident: str): |
| 2150 | 2244 | out: list = [] |
| 2151 | 2245 | _append_mp4_row(out, "series", f"tmdb:{tmdb}", season, episode) |
| 2152 | 2246 | _append_vidcore_row(out, "series", f"tmdb:{tmdb}", season, episode) |
| 2247 | _append_action_rows(out, "series", f"tmdb:{tmdb}", season, episode) | |
| 2153 | 2248 | return jsonify({"streams": out}) |
| 2154 | 2249 | fmv = re.fullmatch(r"fmv:(\d+):([a-z0-9\-]+):(\d+):(\d+)", ident) |
| 2155 | 2250 | if fmv: |
| … | … | @@ -0,0 +1,165 @@ |
| 1 | """The two rows that DO something, in the list Jacktook shows before playing. | |
| 2 | ||
| 3 | The completeness gate catches a copy that is short. It cannot catch the wrong | |
| 4 | episode, the wrong season, or unwatchable audio -- and the person who can is in | |
| 5 | front of the TV holding Kore, which cannot drive Kodi's own UI. The source list | |
| 6 | is the one surface they can reach, so the escape hatch lives there. | |
| 7 | ||
| 8 | These routes are GET, unlike /library/reingest, because a Stremio row is a URL | |
| 9 | the player opens. Kodi opens a stream URL with GET + HEAD + GET, so each action | |
| 10 | fires up to three times and the job starters' de-duplication is what stops one | |
| 11 | click becoming three remuxes. | |
| 12 | ||
| 13 | Run: .venv/bin/python3 -m pytest test_kodi_action_rows.py -q | |
| 14 | """ | |
| 15 | ||
| 16 | import os | |
| 17 | ||
| 18 | import pytest | |
| 19 | ||
| 20 | import addon | |
| 21 | ||
| 22 | ||
| 23 | @pytest.fixture(autouse=True) | |
| 24 | def isolated(tmp_path, monkeypatch): | |
| 25 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path)) | |
| 26 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", | |
| 27 | os.path.join(str(tmp_path), "queue.json")) | |
| 28 | monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", | |
| 29 | os.path.join(str(tmp_path), "verified.json")) | |
| 30 | monkeypatch.setattr(addon, "_library_meta_cache", {}) | |
| 31 | monkeypatch.setattr(addon, "_duration_cache", {}) | |
| 32 | monkeypatch.setattr(addon, "_mp4_jobs", {}) | |
| 33 | monkeypatch.setattr(addon, "_season_jobs", {}) | |
| 34 | monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: { | |
| 35 | "tv/1": {"name": "Show", "first_air_date": "2020-01-01"}, | |
| 36 | "tv/1/season/4/episode/4": {"name": "Four", "runtime": 50}, | |
| 37 | "movie/9": {"title": "Film", "release_date": "2020-01-01", "runtime": 100}, | |
| 38 | }[path]) | |
| 39 | yield | |
| 40 | ||
| 41 | ||
| 42 | @pytest.fixture | |
| 43 | def has_copy(monkeypatch): | |
| 44 | monkeypatch.setattr(addon, "_playable_path", lambda *a, **k: "/some/file.mp4") | |
| 45 | ||
| 46 | ||
| 47 | @pytest.fixture | |
| 48 | def no_copy(monkeypatch): | |
| 49 | monkeypatch.setattr(addon, "_playable_path", lambda *a, **k: None) | |
| 50 | ||
| 51 | ||
| 52 | def _rows(kind="series", ident="tmdb:1", season=4, episode=4): | |
| 53 | out = [] | |
| 54 | with addon.app.test_request_context("/"): | |
| 55 | addon._append_action_rows(out, kind, ident, season, episode) | |
| 56 | return out | |
| 57 | ||
| 58 | ||
| 59 | def _titles(rows): | |
| 60 | return " | ".join(r["title"] for r in rows) | |
| 61 | ||
| 62 | ||
| 63 | # --- which rows are offered ------------------------------------------------- | |
| 64 | ||
| 65 | def test_a_held_copy_gets_a_re_download_row(has_copy): | |
| 66 | assert "re-download" in _titles(_rows()) | |
| 67 | ||
| 68 | ||
| 69 | def test_no_copy_means_no_re_download_row(no_copy): | |
| 70 | """_append_mp4_row's "play & keep" already fetches one. Offering to redo | |
| 71 | nothing would be a dead row.""" | |
| 72 | assert "re-download" not in _titles(_rows()) | |
| 73 | ||
| 74 | ||
| 75 | def test_a_series_always_gets_a_season_row(no_copy): | |
| 76 | assert "get season 4" in _titles(_rows()) | |
| 77 | ||
| 78 | ||
| 79 | def test_a_movie_gets_no_season_row(has_copy): | |
| 80 | titles = _titles(_rows("movie", "tmdb:9", 0, 0)) | |
| 81 | assert "re-download" in titles and "season" not in titles | |
| 82 | ||
| 83 | ||
| 84 | def test_an_unidentifiable_title_gets_no_rows(has_copy): | |
| 85 | assert _rows("series", "tmdb:404", 1, 1) == [] | |
| 86 | ||
| 87 | ||
| 88 | def test_the_rows_point_at_this_host(has_copy): | |
| 89 | urls = [r["url"] for r in _rows()] | |
| 90 | assert any("/refetch/series/tmdb:1:4:4.m3u8" in u for u in urls) | |
| 91 | assert any("/season/series/tmdb:1:4:4.m3u8" in u for u in urls) | |
| 92 | ||
| 93 | ||
| 94 | # --- the routes ------------------------------------------------------------- | |
| 95 | ||
| 96 | @pytest.fixture | |
| 97 | def route(monkeypatch): | |
| 98 | """Routes with the work stubbed, recording what each would have started.""" | |
| 99 | jobs, seasons = [], [] | |
| 100 | monkeypatch.setattr(addon, "_start_mp4_job", | |
| 101 | lambda *a, **k: (jobs.append((a, k)), "running")[1]) | |
| 102 | monkeypatch.setattr(addon, "_start_season_job", | |
| 103 | lambda *a, **k: (seasons.append(a), "running")[1]) | |
| 104 | monkeypatch.setattr(addon, "_resolve_vidcore", | |
| 105 | lambda *a, **k: "http://cdn/master.m3u8") | |
| 106 | monkeypatch.setattr(addon, "_cap_variant", lambda url, h: f"{url}?h={h}") | |
| 107 | return type("R", (), {"jobs": jobs, "seasons": seasons, | |
| 108 | "client": addon.app.test_client()})() | |
| 109 | ||
| 110 | ||
| 111 | def test_refetch_forces_a_re_ingest_and_plays_live(route): | |
| 112 | r = route.client.get("/refetch/series/tmdb:1:4:4.m3u8?h=1080") | |
| 113 | assert r.status_code == 302 and "cdn/master.m3u8" in r.headers["Location"] | |
| 114 | assert route.jobs[0][0] == ("series", "tmdb:1", 4, 4, 1080) | |
| 115 | assert route.jobs[0][1]["force"] is True | |
| 116 | ||
| 117 | ||
| 118 | def test_the_season_row_queues_the_season_and_plays_the_episode(route): | |
| 119 | r = route.client.get("/season/series/tmdb:1:4:4.m3u8?h=1080") | |
| 120 | assert r.status_code == 302 | |
| 121 | assert route.seasons[0] == ("tmdb:1", 4, 1080) | |
| 122 | assert route.jobs == [] # the episode is not forced too | |
| 123 | ||
| 124 | ||
| 125 | def test_a_movie_refetch_works(route): | |
| 126 | r = route.client.get("/refetch/movie/tmdb:9.m3u8?h=1080") | |
| 127 | assert r.status_code == 302 | |
| 128 | assert route.jobs[0][0] == ("movie", "tmdb:9", 0, 0, 1080) | |
| 129 | ||
| 130 | ||
| 131 | def test_a_malformed_ident_is_refused(route): | |
| 132 | for url in ("/refetch/series/nope.m3u8", "/season/series/tmdb:1.m3u8", | |
| 133 | "/refetch/movie/../etc.m3u8"): | |
| 134 | assert route.client.get(url).status_code == 404 | |
| 135 | ||
| 136 | ||
| 137 | def test_an_unresolvable_title_does_not_pretend_to_play(route, monkeypatch): | |
| 138 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: None) | |
| 139 | assert route.client.get("/refetch/series/tmdb:1:4:4.m3u8").status_code == 404 | |
| 140 | ||
| 141 | ||
| 142 | # --- the de-duplication these routes lean on -------------------------------- | |
| 143 | ||
| 144 | def test_kodis_three_requests_start_one_job(monkeypatch): | |
| 145 | """Kodi opens a stream URL with GET + HEAD + GET. Without this guard one | |
| 146 | click would be three remuxes of the same episode.""" | |
| 147 | started = [] | |
| 148 | monkeypatch.setattr(addon.threading, "Thread", | |
| 149 | lambda *a, **k: type("T", (), { | |
| 150 | "start": lambda self: started.append(k.get("args")), | |
| 151 | "daemon": True})()) | |
| 152 | for _ in range(3): | |
| 153 | addon._start_mp4_job("series", "tmdb:1", 4, 4, 1080, force=True) | |
| 154 | assert len(started) == 1 | |
| 155 | ||
| 156 | ||
| 157 | def test_a_second_season_request_is_refused_while_one_runs(monkeypatch): | |
| 158 | monkeypatch.setattr(addon, "_season_jobs", {"season:tmdb:1:4:1080": "running"}) | |
| 159 | started = [] | |
| 160 | monkeypatch.setattr(addon.threading, "Thread", | |
| 161 | lambda *a, **k: type("T", (), { | |
| 162 | "start": lambda self: started.append(1), | |
| 163 | "daemon": True})()) | |
| 164 | assert addon._start_season_job("tmdb:1", 4, 1080) == "running" | |
| 165 | assert started == [] |