| … | … | @@ -21,7 +21,7 @@ import time |
| 21 | 21 | import unicodedata |
| 22 | 22 | from dataclasses import dataclass, field |
| 23 | 23 | from typing import Optional |
| 24 | from urllib.parse import urlparse, unquote, urljoin, parse_qs | |
| 24 | from urllib.parse import urlparse, unquote, urljoin, parse_qs, quote | |
| 25 | 25 | |
| 26 | 26 | import requests |
| 27 | 27 | from flask import Flask, jsonify, request, redirect, abort, Response, send_file |
| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | "version": "0.25.0", | |
| 622 | "version": "0.26.0", | |
| 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"], |
| … | … | @@ -2250,6 +2250,11 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int, |
| 2250 | 2250 | return "cancelled" |
| 2251 | 2251 | if url: |
| 2252 | 2252 | variant = _cap_variant(url, height) |
| 2253 | # The preview plays exactly what is being copied (labs#13). | |
| 2254 | picked = next(((h, bw) for h, bw, uri, _ in _variants(url) | |
| 2255 | if urljoin(url, uri) == variant), (None, None)) | |
| 2256 | _job_update(key, preview={"type": "hls", "url": variant, | |
| 2257 | "height": picked[0], "bandwidth": picked[1]}) | |
| 2253 | 2258 | log.info("ingest start: %s -> %s", key, dest) |
| 2254 | 2259 | _job_update(key, phase="downloading", source="vidcore", |
| 2255 | 2260 | downloading_since=time.time()) |
| … | … | @@ -3129,6 +3134,9 @@ def _ingest_from_hdhub(kind: str, ident: str, season: int, episode: int, |
| 3129 | 3134 | if not picked: |
| 3130 | 3135 | return "no usable file" |
| 3131 | 3136 | f, p = picked |
| 3137 | _job_update(_mp4_key(kind, ident, season, episode, height), | |
| 3138 | preview={"type": "file", "url": f.url, "filename": f.filename, | |
| 3139 | "height": f.height, "size": f.size, "probe": p}) | |
| 3132 | 3140 | try: |
| 3133 | 3141 | seconds = float((p.get("format") or {}).get("duration") or 0) |
| 3134 | 3142 | expected = float(meta.get("runtime") or 0) * 60 |
| … | … | @@ -4061,7 +4069,9 @@ def _web_state(kind: str, tmdb: str, season: int = 0, |
| 4061 | 4069 | states = [_mp4_jobs.get(_mp4_key(kind, ident, season, episode, h)) |
| 4062 | 4070 | for h in VIDCORE_HEIGHTS] |
| 4063 | 4071 | if "running" in states: |
| 4064 | return {"status": "running", | |
| 4072 | key = next(_mp4_key(kind, ident, season, episode, h) | |
| 4073 | for h, st in zip(VIDCORE_HEIGHTS, states) if st == "running") | |
| 4074 | return {"status": "running", "key": key, | |
| 4065 | 4075 | "bytes": _ingest_progress(kind, ident, season, episode)} |
| 4066 | 4076 | failed = next((s for s in states if s and s.startswith("failed")), None) |
| 4067 | 4077 | if failed: |
| … | … | @@ -4079,7 +4089,8 @@ def _web_state_html(state: dict, watch: Optional[str] = None) -> str: |
| 4079 | 4089 | if s == "running": |
| 4080 | 4090 | n = state.get("bytes") |
| 4081 | 4091 | got = f" · {n / 1e6:.0f} MB" if n else " · starting" |
| 4082 | return f"<span class='st running'>downloading{got}</span>" | |
| 4092 | return (f"<span class='st running'>downloading{got}</span>" | |
| 4093 | + _preview_link(state.get("key"))) | |
| 4083 | 4094 | if s == "failed": |
| 4084 | 4095 | return f"<span class='st failed'>{html.escape(state['why'])}</span>" |
| 4085 | 4096 | return "" |
| … | … | @@ -4347,7 +4358,8 @@ def _downloads_active_html() -> tuple[str, bool]: |
| 4347 | 4358 | f"<td>{j.get('height')}p</td>" |
| 4348 | 4359 | f"<td>{html.escape(j.get('source') or '')}</td>" |
| 4349 | 4360 | f"<td>{_human_secs(now - (j.get('started') or now))}</td>" |
| 4350 | f"<td class=act>{_web_cancel_form(key, 'Cancel')}</td></tr>") | |
| 4361 | f"<td class=act>{_preview_link(key)} " | |
| 4362 | f"{_web_cancel_form(key, 'Cancel')}</td></tr>") | |
| 4351 | 4363 | if not rows: |
| 4352 | 4364 | return "<p class=mute>Nothing downloading.</p>", False |
| 4353 | 4365 | return ("<table><tr><th>Title</th><th>Status</th><th>Quality</th>" |
| … | … | @@ -4777,6 +4789,173 @@ def web_watch_series(tmdb: str, season: int, episode: int): |
| 4777 | 4789 | return _web_watch("series", tmdb, season, episode) |
| 4778 | 4790 | |
| 4779 | 4791 | |
| 4792 | # --------------------------------- preview ---------------------------------- | |
| 4793 | # Watch the stream a RUNNING download is copying, judge it, then let it finish | |
| 4794 | # or Cancel. It plays the very source the job copies -- recorded in | |
| 4795 | # _job_info[key]["preview"] when the copy starts -- not a second resolve. The | |
| 4796 | # .part cannot be played instead: +faststart writes the moov atom last. | |
| 4797 | # | |
| 4798 | # Measured 2026-10-04 (labs#13): | |
| 4799 | # vidcore variant playlists are REFERER-GATED -- 403 bare, 200 with | |
| 4800 | # `Referer: https://vidcore.net/` (6/6 on one egress), CORS `*`. | |
| 4801 | # A browser cannot set Referer, so they play through the relay | |
| 4802 | # below, which adds it. | |
| 4803 | # HdHub direct files, no referer. Chromium plays h264 in .mkv as well as | |
| 4804 | # .mp4, so the browser plays them straight from the source. | |
| 4805 | HLS_JS = "https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.6.16/hls.min.js" | |
| 4806 | VIDCORE_REFERER = "https://vidcore.net/" | |
| 4807 | ||
| 4808 | ||
| 4809 | def _preview_link(key: Optional[str]) -> str: | |
| 4810 | if not key or not (_job_info.get(key) or {}).get("preview"): | |
| 4811 | return "" | |
| 4812 | return (f" <a class=watch href='/web/preview?key={quote(key, safe='')}'>" | |
| 4813 | "Preview</a>") | |
| 4814 | ||
| 4815 | ||
| 4816 | def _live_preview(key: str) -> Optional[dict]: | |
| 4817 | return (_job_info.get(key) or {}).get("preview") | |
| 4818 | ||
| 4819 | ||
| 4820 | def _preview_quality_html(pv: dict) -> str: | |
| 4821 | if pv["type"] == "hls": | |
| 4822 | rate = f"{pv['bandwidth'] / 1e6:.1f} Mbps" if pv.get("bandwidth") else "?" | |
| 4823 | rows = [("Source", "vidcore (HLS)"), | |
| 4824 | ("Variant", f"{pv.get('height') or '?'}p · {rate}")] | |
| 4825 | return "<table>" + "".join( | |
| 4826 | f"<tr><td class=mute>{k}</td><td>{html.escape(v)}</td></tr>" | |
| 4827 | for k, v in rows) + "</table>" | |
| 4828 | probe = pv.get("probe") or {} | |
| 4829 | streams = probe.get("streams") or [] | |
| 4830 | video = next((s for s in streams if s.get("codec_type") == "video"), {}) | |
| 4831 | audio = [s for s in streams if s.get("codec_type") == "audio"] | |
| 4832 | rows = [("Source", "HdHub (direct file)"), | |
| 4833 | ("File", pv.get("filename") or ""), | |
| 4834 | ("Picture", f"{video.get('codec_name') or '?'} · " | |
| 4835 | f"{video.get('height') or pv.get('height') or '?'}p"), | |
| 4836 | ("Size", _human_bytes(pv.get("size")))] | |
| 4837 | for i, a in enumerate(audio, 1): | |
| 4838 | lang = (a.get("tags") or {}).get("language") or "und" | |
| 4839 | rows.append((f"Audio {i}", f"{a.get('codec_name')} · {lang}")) | |
| 4840 | warn = "" | |
| 4841 | if audio and audio[0].get("codec_name") in BROWSER_SILENT_AUDIO: | |
| 4842 | warn = ("<p class=warn>This preview plays without sound: the first " | |
| 4843 | f"audio track is {html.escape(audio[0].get('codec_name'))}, " | |
| 4844 | "which browsers cannot decode. The copy keeps it.</p>") | |
| 4845 | return warn + "<table>" + "".join( | |
| 4846 | f"<tr><td class=mute>{k}</td><td>{html.escape(str(v))}</td></tr>" | |
| 4847 | for k, v in rows) + "</table>" | |
| 4848 | ||
| 4849 | ||
| 4850 | @app.route("/web/preview") | |
| 4851 | def web_preview(): | |
| 4852 | key = request.args.get("key") or "" | |
| 4853 | info = _job_info.get(key) | |
| 4854 | if info is None: | |
| 4855 | state = _mp4_jobs.get(key) or "" | |
| 4856 | parts = key.split(":") | |
| 4857 | if state and len(parts) >= 5 and parts[0] in ("movie", "series"): | |
| 4858 | # Finished (or stopped) since the link was rendered: the title page | |
| 4859 | # has the outcome, and Watch if it landed. | |
| 4860 | href = _title_href(parts[0], ":".join(parts[1:-3]), int(parts[-3])) | |
| 4861 | if href: | |
| 4862 | return redirect(href, code=303) | |
| 4863 | abort(404) | |
| 4864 | title = html.escape(str(info.get("title") or info.get("ident"))) | |
| 4865 | head = (f"<h1>Preview: {title} <span class=mute>" | |
| 4866 | f"{html.escape(str(info.get('label') or ''))}</span></h1>") | |
| 4867 | pv = info.get("preview") | |
| 4868 | if not pv: | |
| 4869 | return _web_page("Preview", head | |
| 4870 | + f"<p class=mute>{html.escape(info.get('phase') or '')}" | |
| 4871 | " — the stream appears here once the download starts " | |
| 4872 | "copying.</p>" + _web_cancel_form(key, "Cancel download"), | |
| 4873 | refresh=True) | |
| 4874 | k = quote(key, safe="") | |
| 4875 | if pv["type"] == "hls": | |
| 4876 | player = ( | |
| 4877 | "<video id=pv controls playsinline style='width:100%;max-height:70vh;" | |
| 4878 | "background:#000'></video>" | |
| 4879 | f"<script src='{HLS_JS}'></script><script>" | |
| 4880 | "(function(){var v=document.getElementById('pv')," | |
| 4881 | f"src='/web/relay/playlist?key={k}';" | |
| 4882 | "if(window.Hls&&Hls.isSupported()){var h=new Hls();h.loadSource(src);" | |
| 4883 | "h.attachMedia(v);}else{v.src=src;}})();</script>") | |
| 4884 | else: | |
| 4885 | player = (f"<video controls playsinline preload=metadata src='{html.escape(pv['url'])}' " | |
| 4886 | "style='width:100%;max-height:70vh;background:#000'></video>") | |
| 4887 | body = (head + player | |
| 4888 | + "<p>Downloading in the background. Keep it, or " | |
| 4889 | + _web_cancel_form(key, "Cancel download") + "</p>" | |
| 4890 | + "<h2>What is being copied</h2>" + _preview_quality_html(pv)) | |
| 4891 | return _web_page(f"Preview — {info.get('title') or ''}", body) | |
| 4892 | ||
| 4893 | ||
| 4894 | def _relay_target(key: str) -> dict: | |
| 4895 | pv = _live_preview(key) | |
| 4896 | if not pv or pv.get("type") != "hls": | |
| 4897 | abort(404) | |
| 4898 | return pv | |
| 4899 | ||
| 4900 | ||
| 4901 | @app.route("/web/relay/playlist") | |
| 4902 | def web_relay_playlist(): | |
| 4903 | key = request.args.get("key") or "" | |
| 4904 | pv = _relay_target(key) | |
| 4905 | try: | |
| 4906 | r = requests.get(pv["url"], headers={"User-Agent": UA, | |
| 4907 | "Referer": VIDCORE_REFERER}, | |
| 4908 | timeout=REQUEST_TIMEOUT) | |
| 4909 | r.raise_for_status() | |
| 4910 | except Exception as exc: | |
| 4911 | log.warning("preview: playlist fetch failed for %s (%s)", key, exc) | |
| 4912 | abort(502) | |
| 4913 | k = quote(key, safe="") | |
| 4914 | ||
| 4915 | def relayed(uri: str) -> str: | |
| 4916 | return f"/web/relay/seg?key={k}&u={quote(urljoin(pv['url'], uri), safe='')}" | |
| 4917 | ||
| 4918 | out = [] | |
| 4919 | for line in r.text.splitlines(): | |
| 4920 | if line and not line.startswith("#"): | |
| 4921 | out.append(relayed(line.strip())) | |
| 4922 | elif 'URI="' in line: | |
| 4923 | out.append(re.sub(r'URI="([^"]+)"', | |
| 4924 | lambda m: f'URI="{relayed(m.group(1))}"', line)) | |
| 4925 | else: | |
| 4926 | out.append(line) | |
| 4927 | return Response("\n".join(out) + "\n", | |
| 4928 | mimetype="application/vnd.apple.mpegurl") | |
| 4929 | ||
| 4930 | ||
| 4931 | @app.route("/web/relay/seg") | |
| 4932 | 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.""" | |
| 4935 | key = request.args.get("key") or "" | |
| 4936 | pv = _relay_target(key) | |
| 4937 | u = request.args.get("u") or "" | |
| 4938 | if urlparse(u).scheme != "https" or urlparse(u).netloc != urlparse(pv["url"]).netloc: | |
| 4939 | abort(403) | |
| 4940 | headers = {"User-Agent": UA, "Referer": VIDCORE_REFERER} | |
| 4941 | if request.headers.get("Range"): | |
| 4942 | headers["Range"] = request.headers["Range"] | |
| 4943 | try: | |
| 4944 | up = requests.get(u, headers=headers, timeout=REQUEST_TIMEOUT, stream=True) | |
| 4945 | except Exception as exc: | |
| 4946 | log.warning("preview: segment fetch failed for %s (%s)", key, exc) | |
| 4947 | abort(502) | |
| 4948 | if up.status_code >= 400: | |
| 4949 | up.close() | |
| 4950 | abort(502) | |
| 4951 | passed = {h: up.headers[h] for h in ("Content-Length", "Content-Range", | |
| 4952 | "Accept-Ranges") if h in up.headers} | |
| 4953 | # Segments arrive disguised as text/html; the player only needs bytes. | |
| 4954 | return Response(up.iter_content(64 * 1024), status=up.status_code, | |
| 4955 | headers=passed, mimetype="video/mp4", | |
| 4956 | direct_passthrough=True) | |
| 4957 | ||
| 4958 | ||
| 4780 | 4959 | # The sources this addon knows about. Declared here, at the end, so each entry |
| 4781 | 4960 | # can point at the resolver it uses. Dead and unusable sources are RECORDED |
| 4782 | 4961 | # rather than deleted: the next person to notice that vidlink serves progressive |
| … | … | @@ -0,0 +1,275 @@ |
| 1 | """Preview the stream a running download is copying (labs#13). | |
| 2 | ||
| 3 | Watch it, judge the quality, then let the download finish or Cancel. The | |
| 4 | preview plays the SAME source the job copies -- not a second resolve -- because | |
| 5 | the job already holds the URL. (The .part itself is unplayable: +faststart | |
| 6 | writes the moov atom last.) | |
| 7 | ||
| 8 | Measured 2026-10-04 on stortron (Inception, moon.zenoak.top): | |
| 9 | - vidcore variant playlists are REFERER-GATED: 403 bare, 200 with | |
| 10 | `Referer: https://vidcore.net/` -- 6/6 on one egress. CORS is `*`. A | |
| 11 | browser cannot set Referer, so vidcore previews go through a relay here | |
| 12 | that adds it. | |
| 13 | - HdHub hands out direct files (no referer). Chromium plays h264 .mkv as | |
| 14 | well as .mp4 (aac audible; eac3 silent, as for Watch), so the browser | |
| 15 | plays those straight from the source. | |
| 16 | ||
| 17 | The relay is NOT an open proxy: it only fetches from the host of the live | |
| 18 | job's own playlist. | |
| 19 | ||
| 20 | Run: .venv/bin/python3 -m pytest test_web_preview.py -q | |
| 21 | """ | |
| 22 | ||
| 23 | import os | |
| 24 | import threading | |
| 25 | from urllib.parse import quote | |
| 26 | ||
| 27 | import pytest | |
| 28 | ||
| 29 | import addon | |
| 30 | ||
| 31 | ||
| 32 | KEY = "series:tmdb:1:2:6:1080" | |
| 33 | VARIANT = "https://cdn.example/vd/abc/sd/720.m3u8" | |
| 34 | PLAYLIST = """#EXTM3U | |
| 35 | #EXT-X-VERSION:7 | |
| 36 | #EXT-X-MAP:URI="init-720.mp4" | |
| 37 | #EXTINF:6.0, | |
| 38 | seg-1.html?token=a | |
| 39 | #EXTINF:6.0, | |
| 40 | https://cdn.example/vd/abc/sd/seg-2.html?token=b | |
| 41 | #EXT-X-ENDLIST | |
| 42 | """ | |
| 43 | ||
| 44 | ||
| 45 | @pytest.fixture(autouse=True) | |
| 46 | def isolated(tmp_path, monkeypatch): | |
| 47 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib")) | |
| 48 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "q.json")) | |
| 49 | monkeypatch.setattr(addon, "_job_info", {}) | |
| 50 | monkeypatch.setattr(addon, "_mp4_jobs", {}) | |
| 51 | monkeypatch.setattr(addon, "_enough_space", lambda: True) | |
| 52 | yield | |
| 53 | ||
| 54 | ||
| 55 | @pytest.fixture | |
| 56 | def client(): | |
| 57 | return addon.app.test_client() | |
| 58 | ||
| 59 | ||
| 60 | def _live(preview=None, phase="downloading"): | |
| 61 | addon._job_info[KEY] = { | |
| 62 | "phase": phase, "source": "vidcore", "dest": None, "started": 0, | |
| 63 | "kind": "series", "ident": "tmdb:1", "season": 2, "episode": 6, | |
| 64 | "height": 1080, "label": "S02E06", "title": "Show"} | |
| 65 | if preview: | |
| 66 | addon._job_info[KEY]["preview"] = preview | |
| 67 | addon._mp4_jobs[KEY] = "running" | |
| 68 | ||
| 69 | ||
| 70 | HLS = {"type": "hls", "url": VARIANT, "height": 720, "bandwidth": 3121132} | |
| 71 | FILE = {"type": "file", "url": "https://pub-x.r2.dev/Show.S02E06.mkv?X-Amz=1", | |
| 72 | "filename": "Show.S02E06.1080p.mkv", "height": 1080, "size": 2_000_000_000, | |
| 73 | "probe": {"format": {"duration": "1800"}, "streams": [ | |
| 74 | {"codec_type": "video", "codec_name": "h264", "height": 1080}, | |
| 75 | {"codec_type": "audio", "codec_name": "eac3", "tags": {"language": "eng"}}]}} | |
| 76 | ||
| 77 | ||
| 78 | class FakeResp: | |
| 79 | def __init__(self, body=b"", status=200, headers=None): | |
| 80 | self.content = body | |
| 81 | self.text = body.decode() if isinstance(body, bytes) else body | |
| 82 | self.status_code = status | |
| 83 | self.headers = headers or {} | |
| 84 | ||
| 85 | def raise_for_status(self): | |
| 86 | if self.status_code >= 400: | |
| 87 | raise addon.requests.HTTPError(str(self.status_code)) | |
| 88 | ||
| 89 | def iter_content(self, chunk_size=1): | |
| 90 | yield self.content | |
| 91 | ||
| 92 | def close(self): | |
| 93 | pass | |
| 94 | ||
| 95 | ||
| 96 | # --- the job records what it copies ----------------------------------------- | |
| 97 | ||
| 98 | META = {"tmdb_id": "1", "title": "Show", "year": "2020", | |
| 99 | "episode_title": "Six", "runtime": 30} | |
| 100 | ||
| 101 | ||
| 102 | def _ingest_env(monkeypatch, tmp_path): | |
| 103 | monkeypatch.setattr(addon, "_library_meta", lambda *a, **k: dict(META)) | |
| 104 | monkeypatch.setattr(addon, "_library_has", lambda *a, **k: False) | |
| 105 | monkeypatch.setattr(addon, "_fetch_subtitles_for", lambda *a, **k: None) | |
| 106 | monkeypatch.setattr(addon, "_notify_jellyfin", lambda *a, **k: None) | |
| 107 | addon._job_info[KEY] = {} | |
| 108 | ||
| 109 | ||
| 110 | def test_a_vidcore_job_records_the_variant_it_copies(monkeypatch, tmp_path): | |
| 111 | _ingest_env(monkeypatch, tmp_path) | |
| 112 | master = "https://cdn.example/vd/abc/ma" | |
| 113 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: master) | |
| 114 | monkeypatch.setattr(addon, "_variants", lambda m: [ | |
| 115 | (1080, 6152556, "sd/1080.m3u8", ""), (720, 3121132, "sd/720.m3u8", "")]) | |
| 116 | monkeypatch.setattr(addon, "_cap_variant", lambda m, h=None: VARIANT) | |
| 117 | seen = {} | |
| 118 | ||
| 119 | def remux(url, dest): | |
| 120 | seen.update(addon._job_info[KEY].get("preview") or {}) | |
| 121 | os.makedirs(os.path.dirname(dest), exist_ok=True) | |
| 122 | open(dest, "wb").write(b"x") | |
| 123 | monkeypatch.setattr(addon, "_remux_to_mp4", remux) | |
| 124 | addon._run_ingest("series", "tmdb:1", 2, 6, 1080) | |
| 125 | assert seen == {"type": "hls", "url": VARIANT, "height": 720, | |
| 126 | "bandwidth": 3121132} | |
| 127 | ||
| 128 | ||
| 129 | def test_an_hdhub_job_records_the_file_it_copies(monkeypatch, tmp_path): | |
| 130 | _ingest_env(monkeypatch, tmp_path) | |
| 131 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: None) | |
| 132 | monkeypatch.setattr(addon, "_hdhub_enabled", lambda: True) | |
| 133 | f = addon.HdHubFile(url=FILE["url"], filename=FILE["filename"], height=1080, | |
| 134 | size=FILE["size"], host="pub-x.r2.dev", english_only=True) | |
| 135 | monkeypatch.setattr(addon, "_hdhub_pick", lambda *a, **k: (f, FILE["probe"])) | |
| 136 | seen = {} | |
| 137 | ||
| 138 | def copy(url, probe, dest): | |
| 139 | seen.update(addon._job_info[KEY].get("preview") or {}) | |
| 140 | os.makedirs(os.path.dirname(dest), exist_ok=True) | |
| 141 | open(dest, "wb").write(b"x") | |
| 142 | monkeypatch.setattr(addon, "_copy_progressive", copy) | |
| 143 | addon._run_ingest("series", "tmdb:1", 2, 6, 1080) | |
| 144 | assert seen["type"] == "file" and seen["url"] == FILE["url"] | |
| 145 | assert seen["filename"] == FILE["filename"] and seen["probe"] == FILE["probe"] | |
| 146 | ||
| 147 | ||
| 148 | # --- the page ---------------------------------------------------------------- | |
| 149 | ||
| 150 | def test_an_hls_preview_plays_through_the_relay_with_hls_js(client): | |
| 151 | _live(HLS) | |
| 152 | body = client.get(f"/web/preview?key={quote(KEY)}").get_data(as_text=True) | |
| 153 | assert "hls.js/1.6.16/hls.min.js" in body | |
| 154 | assert "/web/relay/playlist?key=" + quote(KEY, safe="") in body | |
| 155 | assert "720p" in body and "3.1 Mbps" in body | |
| 156 | ||
| 157 | ||
| 158 | def test_a_file_preview_plays_the_source_directly(client): | |
| 159 | _live(FILE) | |
| 160 | body = client.get(f"/web/preview?key={quote(KEY)}").get_data(as_text=True) | |
| 161 | assert "src='https://pub-x.r2.dev/Show.S02E06.mkv?X-Amz=1'" in body | |
| 162 | assert "hls.min.js" not in body | |
| 163 | assert "without sound" in body # eac3 first track | |
| 164 | assert "Show.S02E06.1080p.mkv" in body | |
| 165 | ||
| 166 | ||
| 167 | def test_the_preview_page_has_cancel_and_does_not_auto_refresh(client): | |
| 168 | """A refresh would restart the video under the viewer.""" | |
| 169 | _live(HLS) | |
| 170 | body = client.get(f"/web/preview?key={quote(KEY)}").get_data(as_text=True) | |
| 171 | assert "action='/web/cancel'" in body and f"value='{KEY}'" in body | |
| 172 | assert "http-equiv=refresh" not in body | |
| 173 | ||
| 174 | ||
| 175 | def test_before_the_source_is_known_the_page_waits_and_refreshes(client): | |
| 176 | _live(None, phase="resolving") | |
| 177 | body = client.get(f"/web/preview?key={quote(KEY)}").get_data(as_text=True) | |
| 178 | assert "resolving" in body and "http-equiv=refresh" in body | |
| 179 | assert "<video" not in body | |
| 180 | ||
| 181 | ||
| 182 | def test_a_finished_job_sends_you_to_the_title(client): | |
| 183 | addon._mp4_jobs[KEY] = "done" | |
| 184 | r = client.get(f"/web/preview?key={quote(KEY)}") | |
| 185 | assert r.status_code == 303 | |
| 186 | assert r.headers["Location"].endswith("/web/title/series/1?s=2") | |
| 187 | ||
| 188 | ||
| 189 | def test_an_unknown_job_is_404(client): | |
| 190 | assert client.get("/web/preview?key=nope").status_code == 404 | |
| 191 | ||
| 192 | ||
| 193 | def test_the_downloads_page_links_a_previewable_job(client): | |
| 194 | _live(HLS) | |
| 195 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 196 | assert f"/web/preview?key={quote(KEY, safe='')}" in body | |
| 197 | ||
| 198 | ||
| 199 | def test_the_title_page_links_a_previewable_job(client, monkeypatch): | |
| 200 | monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: { | |
| 201 | "tv/1": {"name": "Show", "first_air_date": "2020-01-01", | |
| 202 | "seasons": [{"season_number": 2}]}, | |
| 203 | "tv/1/season/2": {"episodes": [ | |
| 204 | {"episode_number": 6, "name": "Six", "air_date": "2020-01-01"}]}, | |
| 205 | "tv/1/season/2/episode/6": {"name": "Six", "runtime": 30}}[path]) | |
| 206 | _live(HLS) | |
| 207 | body = client.get("/web/title/series/1?s=2").get_data(as_text=True) | |
| 208 | assert f"/web/preview?key={quote(KEY, safe='')}" in body | |
| 209 | ||
| 210 | ||
| 211 | # --- the relay --------------------------------------------------------------- | |
| 212 | ||
| 213 | def test_the_playlist_is_fetched_with_the_vidcore_referer_and_rewritten(client, monkeypatch): | |
| 214 | _live(HLS) | |
| 215 | calls = [] | |
| 216 | ||
| 217 | def get(url, headers=None, timeout=None, stream=False): | |
| 218 | calls.append((url, headers)) | |
| 219 | return FakeResp(PLAYLIST.encode()) | |
| 220 | monkeypatch.setattr(addon.requests, "get", get) | |
| 221 | r = client.get(f"/web/relay/playlist?key={quote(KEY)}") | |
| 222 | assert r.status_code == 200 | |
| 223 | assert r.mimetype == "application/vnd.apple.mpegurl" | |
| 224 | assert calls[0][0] == VARIANT | |
| 225 | assert calls[0][1]["Referer"] == "https://vidcore.net/" | |
| 226 | body = r.get_data(as_text=True) | |
| 227 | k = quote(KEY, safe="") | |
| 228 | assert f'URI="/web/relay/seg?key={k}&u=' + quote("https://cdn.example/vd/abc/sd/init-720.mp4", safe="") + '"' in body | |
| 229 | assert f"/web/relay/seg?key={k}&u=" + quote("https://cdn.example/vd/abc/sd/seg-1.html?token=a", safe="") in body | |
| 230 | assert f"/web/relay/seg?key={k}&u=" + quote("https://cdn.example/vd/abc/sd/seg-2.html?token=b", safe="") in body | |
| 231 | assert "#EXT-X-ENDLIST" in body | |
| 232 | ||
| 233 | ||
| 234 | def test_a_segment_is_relayed_with_the_referer_and_range(client, monkeypatch): | |
| 235 | _live(HLS) | |
| 236 | calls = [] | |
| 237 | ||
| 238 | def get(url, headers=None, timeout=None, stream=False): | |
| 239 | calls.append((url, headers)) | |
| 240 | return FakeResp(b"MOOF", status=206, | |
| 241 | headers={"Content-Type": "text/html", | |
| 242 | "Content-Range": "bytes 0-3/10"}) | |
| 243 | monkeypatch.setattr(addon.requests, "get", get) | |
| 244 | u = "https://cdn.example/vd/abc/sd/seg-1.html?token=a" | |
| 245 | r = client.get(f"/web/relay/seg?key={quote(KEY)}&u={quote(u, safe='')}", | |
| 246 | headers={"Range": "bytes=0-3"}) | |
| 247 | assert r.status_code == 206 | |
| 248 | assert r.get_data() == b"MOOF" | |
| 249 | assert r.headers["Content-Range"] == "bytes 0-3/10" | |
| 250 | assert calls[0][0] == u | |
| 251 | assert calls[0][1]["Referer"] == "https://vidcore.net/" | |
| 252 | assert calls[0][1]["Range"] == "bytes=0-3" | |
| 253 | ||
| 254 | ||
| 255 | def test_the_relay_is_not_an_open_proxy(client, monkeypatch): | |
| 256 | _live(HLS) | |
| 257 | monkeypatch.setattr(addon.requests, "get", | |
| 258 | lambda *a, **k: pytest.fail("fetched a foreign host")) | |
| 259 | u = "https://evil.example/x" | |
| 260 | assert client.get(f"/web/relay/seg?key={quote(KEY)}&u={quote(u, safe='')}").status_code == 403 | |
| 261 | ||
| 262 | ||
| 263 | def test_the_relay_needs_a_live_hls_job(client, monkeypatch): | |
| 264 | monkeypatch.setattr(addon.requests, "get", | |
| 265 | lambda *a, **k: pytest.fail("fetched without a job")) | |
| 266 | assert client.get(f"/web/relay/playlist?key={quote(KEY)}").status_code == 404 | |
| 267 | _live(FILE) | |
| 268 | assert client.get(f"/web/relay/playlist?key={quote(KEY)}").status_code == 404 | |
| 269 | ||
| 270 | ||
| 271 | def test_an_upstream_error_is_passed_on_not_crashed(client, monkeypatch): | |
| 272 | _live(HLS) | |
| 273 | monkeypatch.setattr(addon.requests, "get", | |
| 274 | lambda *a, **k: FakeResp(b"no", status=403)) | |
| 275 | assert client.get(f"/web/relay/playlist?key={quote(KEY)}").status_code == 502 |