| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | "version": "0.26.0", | |
| 622 | "version": "0.26.1", | |
| 623 | 623 | "name": "Tabertron VsEmbed", |
| 624 | 624 | "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub", |
| 625 | 625 | "types": ["movie", "series"], |
| … | … | @@ -4696,7 +4696,10 @@ def _delete_copy(kind: str, tmdb: str, season: int, episode: int) -> tuple[int, |
| 4696 | 4696 | "took": 0, "dest": dest}) |
| 4697 | 4697 | finally: |
| 4698 | 4698 | lock.release() |
| 4699 | _notify_jellyfin(dest, "Deleted") | |
| 4699 | # In the background: measured live, Jellyfin took ~5 s to answer, and the | |
| 4700 | # click waited on it. The delete is complete either way. | |
| 4701 | threading.Thread(target=_notify_jellyfin, args=(dest, "Deleted"), | |
| 4702 | name="jellyfin-notify", daemon=True).start() | |
| 4700 | 4703 | return 200, f"deleted {os.path.basename(dest)}" |
| 4701 | 4704 | |
| 4702 | 4705 | |
| … | … | @@ -4911,9 +4914,15 @@ def web_relay_playlist(): |
| 4911 | 4914 | log.warning("preview: playlist fetch failed for %s (%s)", key, exc) |
| 4912 | 4915 | abort(502) |
| 4913 | 4916 | k = quote(key, safe="") |
| 4917 | # Exactly the URLs this playlist lists may be relayed. Not "same host as | |
| 4918 | # the playlist": measured live, segments came from a different CDN host | |
| 4919 | # (paperlake.top) than the playlist itself. | |
| 4920 | allowed = pv.setdefault("allowed", set()) | |
| 4914 | 4921 | |
| 4915 | 4922 | def relayed(uri: str) -> str: |
| 4916 | return f"/web/relay/seg?key={k}&u={quote(urljoin(pv['url'], uri), safe='')}" | |
| 4923 | absolute = urljoin(pv["url"], uri) | |
| 4924 | allowed.add(absolute) | |
| 4925 | return f"/web/relay/seg?key={k}&u={quote(absolute, safe='')}" | |
| 4917 | 4926 | |
| 4918 | 4927 | out = [] |
| 4919 | 4928 | for line in r.text.splitlines(): |
| … | … | @@ -4930,12 +4939,12 @@ def web_relay_playlist(): |
| 4930 | 4939 | |
| 4931 | 4940 | @app.route("/web/relay/seg") |
| 4932 | 4941 | def web_relay_seg(): |
| 4933 | """One segment, fetched with the referer the CDN demands. Only from the | |
| 4934 | host of this job's own playlist -- never an open proxy.""" | |
| 4942 | """One segment, fetched with the referer the CDN demands. Only a URL the | |
| 4943 | job's own relayed playlist listed -- never an open proxy.""" | |
| 4935 | 4944 | key = request.args.get("key") or "" |
| 4936 | 4945 | pv = _relay_target(key) |
| 4937 | 4946 | u = request.args.get("u") or "" |
| 4938 | if urlparse(u).scheme != "https" or urlparse(u).netloc != urlparse(pv["url"]).netloc: | |
| 4947 | if u not in pv.get("allowed", ()): | |
| 4939 | 4948 | abort(403) |
| 4940 | 4949 | headers = {"User-Agent": UA, "Referer": VIDCORE_REFERER} |
| 4941 | 4950 | if request.headers.get("Range"): |
| … | … | @@ -209,11 +209,28 @@ def test_a_movie_delete_returns_to_the_movie(client): |
| 209 | 209 | |
| 210 | 210 | |
| 211 | 211 | def test_delete_notifies_jellyfin(client, isolated): |
| 212 | import threading | |
| 212 | 213 | path = _hold("series", 2, 6) |
| 213 | 214 | _post(client) |
| 215 | for t in threading.enumerate(): | |
| 216 | if t.name == "jellyfin-notify": | |
| 217 | t.join(5) | |
| 214 | 218 | assert isolated == [(path, "Deleted")] |
| 215 | 219 | |
| 216 | 220 | |
| 221 | def test_delete_does_not_wait_for_jellyfin(client, monkeypatch): | |
| 222 | """Measured live: Jellyfin took ~5 s to answer and the click waited.""" | |
| 223 | import threading | |
| 224 | import time | |
| 225 | gate = threading.Event() | |
| 226 | monkeypatch.setattr(addon, "_notify_jellyfin", lambda p, t: gate.wait(5)) | |
| 227 | _hold("series", 2, 6) | |
| 228 | t0 = time.time() | |
| 229 | assert _post(client).status_code == 303 | |
| 230 | assert time.time() - t0 < 1 | |
| 231 | gate.set() | |
| 232 | ||
| 233 | ||
| 217 | 234 | @pytest.mark.parametrize("data", [ |
| 218 | 235 | {"kind": "series", "tmdb": "../1", "season": "2", "episode": "6"}, |
| 219 | 236 | {"kind": "anime", "tmdb": "1", "season": "2", "episode": "6"}, |
| … | … | @@ -63,7 +63,9 @@ def _live(preview=None, phase="downloading"): |
| 63 | 63 | "kind": "series", "ident": "tmdb:1", "season": 2, "episode": 6, |
| 64 | 64 | "height": 1080, "label": "S02E06", "title": "Show"} |
| 65 | 65 | if preview: |
| 66 | addon._job_info[KEY]["preview"] = preview | |
| 66 | # A copy: the relay records its allow-list on the preview dict, and a | |
| 67 | # shared module-level dict let one test's allow-list pass another. | |
| 68 | addon._job_info[KEY]["preview"] = dict(preview) | |
| 67 | 69 | addon._mp4_jobs[KEY] = "running" |
| 68 | 70 | |
| 69 | 71 | |
| … | … | @@ -237,10 +239,14 @@ def test_a_segment_is_relayed_with_the_referer_and_range(client, monkeypatch): |
| 237 | 239 | |
| 238 | 240 | def get(url, headers=None, timeout=None, stream=False): |
| 239 | 241 | calls.append((url, headers)) |
| 242 | if url == VARIANT: | |
| 243 | return FakeResp(PLAYLIST.encode()) | |
| 240 | 244 | return FakeResp(b"MOOF", status=206, |
| 241 | 245 | headers={"Content-Type": "text/html", |
| 242 | 246 | "Content-Range": "bytes 0-3/10"}) |
| 243 | 247 | monkeypatch.setattr(addon.requests, "get", get) |
| 248 | client.get(f"/web/relay/playlist?key={quote(KEY)}") # as hls.js does first | |
| 249 | calls.clear() | |
| 244 | 250 | u = "https://cdn.example/vd/abc/sd/seg-1.html?token=a" |
| 245 | 251 | r = client.get(f"/web/relay/seg?key={quote(KEY)}&u={quote(u, safe='')}", |
| 246 | 252 | headers={"Range": "bytes=0-3"}) |
| … | … | @@ -257,6 +263,7 @@ def test_the_relay_is_not_an_open_proxy(client, monkeypatch): |
| 257 | 263 | monkeypatch.setattr(addon.requests, "get", |
| 258 | 264 | lambda *a, **k: pytest.fail("fetched a foreign host")) |
| 259 | 265 | u = "https://evil.example/x" |
| 266 | # Never listed by any playlist, so never fetched. | |
| 260 | 267 | assert client.get(f"/web/relay/seg?key={quote(KEY)}&u={quote(u, safe='')}").status_code == 403 |
| 261 | 268 | |
| 262 | 269 | |
| … | … | @@ -273,3 +280,31 @@ def test_an_upstream_error_is_passed_on_not_crashed(client, monkeypatch): |
| 273 | 280 | monkeypatch.setattr(addon.requests, "get", |
| 274 | 281 | lambda *a, **k: FakeResp(b"no", status=403)) |
| 275 | 282 | assert client.get(f"/web/relay/playlist?key={quote(KEY)}").status_code == 502 |
| 283 | ||
| 284 | ||
| 285 | def test_segments_on_a_different_cdn_host_are_relayed(client, monkeypatch): | |
| 286 | """Measured live 2026-10-04: the playlist came from one host and its | |
| 287 | segments from another (paperlake.top). The relay allows exactly the URLs | |
| 288 | the job's own relayed playlist listed -- still not an open proxy.""" | |
| 289 | _live(HLS) | |
| 290 | other = "https://paperlake.top/vd/x/seg-9.html?token=z" | |
| 291 | body = PLAYLIST.replace("seg-1.html?token=a", other) | |
| 292 | calls = [] | |
| 293 | ||
| 294 | def get(url, headers=None, timeout=None, stream=False): | |
| 295 | calls.append(url) | |
| 296 | return FakeResp(body.encode() if url == VARIANT else b"SEG", status=200) | |
| 297 | monkeypatch.setattr(addon.requests, "get", get) | |
| 298 | assert client.get(f"/web/relay/playlist?key={quote(KEY)}").status_code == 200 | |
| 299 | r = client.get(f"/web/relay/seg?key={quote(KEY)}&u={quote(other, safe='')}") | |
| 300 | assert r.status_code == 200 and r.get_data() == b"SEG" | |
| 301 | stranger = "https://paperlake.top/vd/x/not-listed.html" | |
| 302 | assert client.get(f"/web/relay/seg?key={quote(KEY)}&u={quote(stranger, safe='')}").status_code == 403 | |
| 303 | ||
| 304 | ||
| 305 | def test_a_segment_before_any_playlist_is_refused(client, monkeypatch): | |
| 306 | _live(HLS) | |
| 307 | monkeypatch.setattr(addon.requests, "get", | |
| 308 | lambda *a, **k: pytest.fail("fetched an unlisted URL")) | |
| 309 | u = "https://cdn.example/vd/abc/sd/seg-1.html?token=a" | |
| 310 | assert client.get(f"/web/relay/seg?key={quote(KEY)}&u={quote(u, safe='')}").status_code == 403 |