| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | "version": "0.21.0", | |
| 622 | "version": "0.22.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"], |
| … | … | @@ -4017,7 +4017,8 @@ def _web_page(title: str, body: str, refresh: bool = False, |
| 4017 | 4017 | + (f"<meta http-equiv=refresh content={WEB_REFRESH_SECONDS}>" |
| 4018 | 4018 | if refresh else "") |
| 4019 | 4019 | + f"<title>{html.escape(title)}</title><style>{_WEB_CSS}</style>") |
| 4020 | nav = ("<header><a href='/'>vsembed</a><a href='/library'>Library</a>" | |
| 4020 | nav = ("<header><a href='/'>vsembed</a>" | |
| 4021 | "<a href='/web/downloads'>Downloads</a><a href='/library'>Library</a>" | |
| 4021 | 4022 | "<form method=get action='/web/search'>" |
| 4022 | 4023 | "<input type=search name=q placeholder='Find a film or show' required>" |
| 4023 | 4024 | "<select name=type><option value=''>All</option>" |
| … | … | @@ -4061,6 +4062,8 @@ def _web_state(kind: str, tmdb: str, season: int = 0, |
| 4061 | 4062 | failed = next((s for s in states if s and s.startswith("failed")), None) |
| 4062 | 4063 | if failed: |
| 4063 | 4064 | return {"status": "failed", "why": failed} |
| 4065 | if "cancelled" in states: | |
| 4066 | return {"status": "failed", "why": "cancelled"} | |
| 4064 | 4067 | return {"status": "none"} |
| 4065 | 4068 | |
| 4066 | 4069 | |
| … | … | @@ -4269,6 +4272,131 @@ def web_download(): |
| 4269 | 4272 | return redirect(back, code=303) |
| 4270 | 4273 | |
| 4271 | 4274 | |
| 4275 | def _human_bytes(n: Optional[float]) -> str: | |
| 4276 | if not n: | |
| 4277 | return "—" | |
| 4278 | return f"{n / 1e9:.1f} GB" if n >= 1e9 else f"{n / 1e6:.0f} MB" | |
| 4279 | ||
| 4280 | ||
| 4281 | def _human_secs(s: Optional[float]) -> str: | |
| 4282 | if s is None: | |
| 4283 | return "—" | |
| 4284 | s = int(s) | |
| 4285 | return f"{s // 60}m {s % 60:02d}s" if s >= 60 else f"{s}s" | |
| 4286 | ||
| 4287 | ||
| 4288 | def _title_href(kind: Optional[str], ident: Optional[str], | |
| 4289 | season: Optional[int]) -> Optional[str]: | |
| 4290 | """Title page for a job, when its ident is a TMDB one (web and fxl jobs). | |
| 4291 | A tt... ident from a cinemeta row has no page to link to.""" | |
| 4292 | if kind not in ("movie", "series") or not (ident or "").startswith("tmdb:"): | |
| 4293 | return None | |
| 4294 | href = f"/web/title/{kind}/{ident.split(':', 1)[1]}" | |
| 4295 | return href + f"?s={season}" if kind == "series" else href | |
| 4296 | ||
| 4297 | ||
| 4298 | def _downloads_active_html() -> tuple[str, bool]: | |
| 4299 | now = time.time() | |
| 4300 | rows = [] | |
| 4301 | with _season_guard: | |
| 4302 | seasons = {k: dict(v) for k, v in _season_info.items()} | |
| 4303 | for key, s in seasons.items(): | |
| 4304 | total = len(s.get("episodes") or []) | |
| 4305 | where = (f"episode {s.get('index') or 0} of {total}" if total | |
| 4306 | else "listing episodes") | |
| 4307 | rows.append( | |
| 4308 | f"<tr><td>{html.escape(str(s.get('title') or s.get('ident')))}" | |
| 4309 | f"<br><span class=mute>Season {s.get('season')} · {where}</span></td>" | |
| 4310 | f"<td>season</td><td>{s.get('height')}p</td><td></td>" | |
| 4311 | f"<td>{_human_secs(now - (s.get('started') or now))}</td>" | |
| 4312 | f"<td class=act>{_web_cancel_form(key, 'Cancel season')}</td></tr>") | |
| 4313 | with _mp4_jobs_guard: | |
| 4314 | jobs = {k: dict(v) for k, v in _job_info.items()} | |
| 4315 | for key, j in sorted(jobs.items(), key=lambda kv: kv[1].get("started") or 0): | |
| 4316 | phase = j.get("phase") or "" | |
| 4317 | progress = "" | |
| 4318 | if phase == "downloading" and j.get("dest"): | |
| 4319 | try: | |
| 4320 | got = os.path.getsize(_part_path(j["dest"])) | |
| 4321 | except OSError: | |
| 4322 | got = 0 | |
| 4323 | since = j.get("downloading_since") or j.get("started") or now | |
| 4324 | rate = got / max(now - since, 1e-6) | |
| 4325 | progress = f" · {_human_bytes(got)} · {rate / 1e6:.1f} MB/s" | |
| 4326 | title = html.escape(str(j.get("title") or j.get("ident"))) | |
| 4327 | href = _title_href(j.get("kind"), j.get("ident"), j.get("season")) | |
| 4328 | if href: | |
| 4329 | title = f"<a href='{href}'>{title}</a>" | |
| 4330 | rows.append( | |
| 4331 | f"<tr><td>{title}<br><span class=mute>" | |
| 4332 | f"{html.escape(str(j.get('label') or ''))}</span></td>" | |
| 4333 | f"<td><span class='st running'>{html.escape(phase)}{progress}</span></td>" | |
| 4334 | f"<td>{j.get('height')}p</td>" | |
| 4335 | f"<td>{html.escape(j.get('source') or '')}</td>" | |
| 4336 | f"<td>{_human_secs(now - (j.get('started') or now))}</td>" | |
| 4337 | f"<td class=act>{_web_cancel_form(key, 'Cancel')}</td></tr>") | |
| 4338 | if not rows: | |
| 4339 | return "<p class=mute>Nothing downloading.</p>", False | |
| 4340 | return ("<table><tr><th>Title</th><th>Status</th><th>Quality</th>" | |
| 4341 | "<th>Source</th><th>Elapsed</th><th></th></tr>" | |
| 4342 | + "".join(rows) + "</table>"), True | |
| 4343 | ||
| 4344 | ||
| 4345 | def _web_cancel_form(key: str, label: str) -> str: | |
| 4346 | return ("<form class=dl method=post action='/web/cancel'>" | |
| 4347 | f"<input type=hidden name='key' value='{html.escape(key)}'>" | |
| 4348 | f"<button>{label}</button></form>") | |
| 4349 | ||
| 4350 | ||
| 4351 | def _downloads_history_html() -> str: | |
| 4352 | entries = _read_history(200) | |
| 4353 | if not entries: | |
| 4354 | return "<p class=mute>No downloads yet.</p>" | |
| 4355 | rows = [] | |
| 4356 | for h in entries: | |
| 4357 | outcome = str(h.get("outcome") or "") | |
| 4358 | cls = ("held" if outcome == "done" else | |
| 4359 | "mute" if outcome == "cancelled" else "failed") | |
| 4360 | title = html.escape(str(h.get("title") or h.get("ident") or "")) | |
| 4361 | href = _title_href(h.get("kind"), h.get("ident"), h.get("season")) | |
| 4362 | if href: | |
| 4363 | title = f"<a href='{href}'>{title}</a>" | |
| 4364 | when = time.strftime("%b %d %H:%M", time.localtime(h.get("ts") or 0)) | |
| 4365 | source = h.get("source") or ("already held" if outcome == "done" else "") | |
| 4366 | rows.append( | |
| 4367 | f"<tr><td class=mute>{when}</td>" | |
| 4368 | f"<td>{title}<br><span class=mute>" | |
| 4369 | f"{html.escape(str(h.get('label') or ''))}</span></td>" | |
| 4370 | f"<td><span class='st {cls}'>{html.escape(outcome)}</span></td>" | |
| 4371 | f"<td>{html.escape(source)}</td>" | |
| 4372 | f"<td class=n>{_human_bytes(h.get('bytes'))}</td>" | |
| 4373 | f"<td class=n>{_human_secs(h.get('took'))}</td></tr>") | |
| 4374 | return ("<table><tr><th>When</th><th>Title</th><th>Outcome</th>" | |
| 4375 | "<th>Source</th><th>Size</th><th>Took</th></tr>" | |
| 4376 | + "".join(rows) + "</table>") | |
| 4377 | ||
| 4378 | ||
| 4379 | @app.route("/web/downloads") | |
| 4380 | def web_downloads(): | |
| 4381 | active, live = _downloads_active_html() | |
| 4382 | body = ("<h1>Downloads</h1>" + _web_low_space() | |
| 4383 | + "<h2>Now</h2>" + active | |
| 4384 | + "<h2>History</h2>" + _downloads_history_html()) | |
| 4385 | return _web_page("Downloads — vsembed", body, refresh=live) | |
| 4386 | ||
| 4387 | ||
| 4388 | @app.route("/web/cancel", methods=["POST"]) | |
| 4389 | def web_cancel(): | |
| 4390 | """POST only. A key that no longer names a live job is not an error: the | |
| 4391 | job finished between the page rendering and the click.""" | |
| 4392 | key = request.form.get("key") or "" | |
| 4393 | if key.startswith("season:"): | |
| 4394 | _cancel_season(key) | |
| 4395 | elif key: | |
| 4396 | _cancel_job(key) | |
| 4397 | return redirect("/web/downloads", code=303) | |
| 4398 | ||
| 4399 | ||
| 4272 | 4400 | # The sources this addon knows about. Declared here, at the end, so each entry |
| 4273 | 4401 | # can point at the resolver it uses. Dead and unusable sources are RECORDED |
| 4274 | 4402 | # rather than deleted: the next person to notice that vidlink serves progressive |
| … | … | @@ -0,0 +1,158 @@ |
| 1 | """The download manager page (labs#13): what is running, and what happened. | |
| 2 | ||
| 3 | Reads only what test_job_control's machinery already maintains: _job_info and | |
| 4 | _season_info for live jobs, ingest_history.jsonl for the past. Cancel is POST | |
| 5 | only, like every other action on this service. | |
| 6 | ||
| 7 | Run: .venv/bin/python3 -m pytest test_web_downloads.py -q | |
| 8 | """ | |
| 9 | ||
| 10 | import json | |
| 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, "INGEST_HISTORY_FILE", str(tmp_path / "history.jsonl")) | |
| 22 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json")) | |
| 23 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib")) | |
| 24 | monkeypatch.setattr(addon, "_job_info", {}) | |
| 25 | monkeypatch.setattr(addon, "_season_info", {}) | |
| 26 | monkeypatch.setattr(addon, "_mp4_jobs", {}) | |
| 27 | monkeypatch.setattr(addon, "_enough_space", lambda: True) | |
| 28 | yield | |
| 29 | ||
| 30 | ||
| 31 | @pytest.fixture | |
| 32 | def client(): | |
| 33 | return addon.app.test_client() | |
| 34 | ||
| 35 | ||
| 36 | def _live(tmp_path, phase="downloading", part_bytes=50_000_000, since_ago=10): | |
| 37 | dest = str(tmp_path / "lib" / "Show - S02E06.mp4") | |
| 38 | os.makedirs(os.path.dirname(dest), exist_ok=True) | |
| 39 | if part_bytes: | |
| 40 | with open(addon._part_path(dest), "wb") as f: | |
| 41 | f.truncate(part_bytes) | |
| 42 | now = time.time() | |
| 43 | addon._job_info["series:tmdb:1:2:6:1080"] = { | |
| 44 | "phase": phase, "source": "vidcore", "dest": dest, "started": now - 30, | |
| 45 | "downloading_since": now - since_ago, "kind": "series", | |
| 46 | "ident": "tmdb:1", "season": 2, "episode": 6, "height": 1080, | |
| 47 | "label": "S02E06", "title": "Show <i>x</i>"} | |
| 48 | ||
| 49 | ||
| 50 | def _history(*entries): | |
| 51 | with open(addon.INGEST_HISTORY_FILE, "w") as f: | |
| 52 | for e in entries: | |
| 53 | f.write(json.dumps(e) + "\n") | |
| 54 | ||
| 55 | ||
| 56 | def test_an_empty_manager_says_so(client): | |
| 57 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 58 | assert "Nothing downloading" in body | |
| 59 | assert "No downloads yet" in body | |
| 60 | assert "http-equiv=refresh" not in body | |
| 61 | ||
| 62 | ||
| 63 | def test_a_live_job_shows_title_phase_source_progress_and_speed(client, tmp_path): | |
| 64 | _live(tmp_path) | |
| 65 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 66 | assert "Show <i>x</i>" in body and "S02E06" in body | |
| 67 | assert "downloading" in body and "vidcore" in body and "1080p" in body | |
| 68 | assert "50 MB" in body | |
| 69 | assert "MB/s" in body | |
| 70 | assert "http-equiv=refresh" in body | |
| 71 | ||
| 72 | ||
| 73 | def test_a_live_job_has_a_cancel_form(client, tmp_path): | |
| 74 | _live(tmp_path) | |
| 75 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 76 | assert "action='/web/cancel'" in body | |
| 77 | assert "value='series:tmdb:1:2:6:1080'" in body | |
| 78 | ||
| 79 | ||
| 80 | def test_a_resolving_job_shows_no_speed(client, tmp_path): | |
| 81 | _live(tmp_path, phase="resolving", part_bytes=0) | |
| 82 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 83 | assert "resolving" in body and "MB/s" not in body | |
| 84 | ||
| 85 | ||
| 86 | def test_a_season_shows_its_position_and_its_own_cancel(client): | |
| 87 | addon._season_info["season:tmdb:1:2:1080"] = { | |
| 88 | "ident": "tmdb:1", "season": 2, "height": 1080, "title": "Show", | |
| 89 | "episodes": [1, 2, 3], "index": 2, "current": "series:tmdb:1:2:2:1080", | |
| 90 | "started": time.time()} | |
| 91 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 92 | assert "episode 2 of 3" in body | |
| 93 | assert "value='season:tmdb:1:2:1080'" in body | |
| 94 | ||
| 95 | ||
| 96 | def test_history_lists_newest_first_with_outcome_source_and_size(client): | |
| 97 | _history( | |
| 98 | {"ts": 1000, "title": "Old", "label": "movie", "outcome": "done", | |
| 99 | "source": "hdhub", "bytes": 2_500_000_000, "took": 300, "kind": "movie", | |
| 100 | "ident": "tmdb:2", "season": 0, "episode": 0}, | |
| 101 | {"ts": 2000, "title": "New", "label": "S01E01", | |
| 102 | "outcome": "failed: could not resolve title", "source": "vidcore", | |
| 103 | "bytes": None, "took": 30, "kind": "series", "ident": "tmdb:1", | |
| 104 | "season": 1, "episode": 1}) | |
| 105 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 106 | assert body.index("New") < body.index("Old") | |
| 107 | assert "could not resolve title" in body | |
| 108 | assert "hdhub" in body and "2.5 GB" in body | |
| 109 | ||
| 110 | ||
| 111 | def test_history_links_back_to_the_title(client): | |
| 112 | _history({"ts": 1, "title": "Show", "label": "S01E01", "outcome": "done", | |
| 113 | "source": "vidcore", "bytes": 1, "took": 1, "kind": "series", | |
| 114 | "ident": "tmdb:1", "season": 1, "episode": 1}) | |
| 115 | body = client.get("/web/downloads").get_data(as_text=True) | |
| 116 | assert "/web/title/series/1?s=1" in body | |
| 117 | ||
| 118 | ||
| 119 | def test_cancel_posts_to_the_job(client, monkeypatch): | |
| 120 | asked = [] | |
| 121 | monkeypatch.setattr(addon, "_cancel_job", lambda k: asked.append(k) or True) | |
| 122 | r = client.post("/web/cancel", data={"key": "series:tmdb:1:2:6:1080"}) | |
| 123 | assert r.status_code == 303 | |
| 124 | assert r.headers["Location"].endswith("/web/downloads") | |
| 125 | assert asked == ["series:tmdb:1:2:6:1080"] | |
| 126 | ||
| 127 | ||
| 128 | def test_cancel_routes_a_season_key_to_the_season(client, monkeypatch): | |
| 129 | asked = [] | |
| 130 | monkeypatch.setattr(addon, "_cancel_season", lambda k: asked.append(k) or True) | |
| 131 | client.post("/web/cancel", data={"key": "season:tmdb:1:2:1080"}) | |
| 132 | assert asked == ["season:tmdb:1:2:1080"] | |
| 133 | ||
| 134 | ||
| 135 | def test_cancel_of_a_job_that_already_finished_is_harmless(client): | |
| 136 | r = client.post("/web/cancel", data={"key": "series:tmdb:9:9:9:1080"}) | |
| 137 | assert r.status_code == 303 | |
| 138 | ||
| 139 | ||
| 140 | def test_a_get_can_never_cancel(client, monkeypatch): | |
| 141 | asked = [] | |
| 142 | monkeypatch.setattr(addon, "_cancel_job", lambda k: asked.append(k)) | |
| 143 | assert client.get("/web/cancel?key=x").status_code == 405 | |
| 144 | assert asked == [] | |
| 145 | ||
| 146 | ||
| 147 | def test_every_page_links_to_the_manager(client): | |
| 148 | assert "href='/web/downloads'" in client.get("/").get_data(as_text=True) | |
| 149 | ||
| 150 | ||
| 151 | def test_a_cancelled_title_offers_download_again(client, monkeypatch): | |
| 152 | monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: { | |
| 153 | "movie/2": {"title": "Film", "release_date": "2021-01-01", "runtime": 100}, | |
| 154 | "trending/tv/week": {"results": []}}[path]) | |
| 155 | addon._mp4_jobs["movie:tmdb:2:0:0:1080"] = "cancelled" | |
| 156 | body = client.get("/web/title/movie/2").get_data(as_text=True) | |
| 157 | assert "cancelled" in body | |
| 158 | assert "action='/web/download'" in body |