OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 0c57f55

Download manager page: live jobs, cancel, history (0.22.0)
labs#13 part 2. /web/downloads lists what is running -- title, phase, MB written and average MB/s (from the .part, as _ingest_progress does), quality, source, elapsed -- with Cancel per job and per season ("episode k of M"), then the attempt history newest first (outcome, source, size, took), linking back to title pages. Refreshes only while something is live. POST /web/cancel routes season:… keys to _cancel_season, others to _cancel_job; a key whose job already finished is a harmless no-op. Title pages now show "cancelled" and offer Download again. Nav gains Downloads. 13 tests. Suite 403. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0c57f55965e452177c9421c1c1dd247fa5ee347a
by plankatron <fleeky@gmail.com> · 2026-10-05T00:44:35.000Z

Changed files (2) +288 -2

addon.py +130 -2
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.21.0",
622 "version": "0.22.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -4017,7 +4017,8 @@ def _web_page(title: str, body: str, refresh: bool = False,
40174017 + (f"<meta http-equiv=refresh content={WEB_REFRESH_SECONDS}>"
40184018 if refresh else "")
40194019 + 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>"
40214022 "<form method=get action='/web/search'>"
40224023 "<input type=search name=q placeholder='Find a film or show' required>"
40234024 "<select name=type><option value=''>All</option>"
……@@ -4061,6 +4062,8 @@ def _web_state(kind: str, tmdb: str, season: int = 0,
40614062 failed = next((s for s in states if s and s.startswith("failed")), None)
40624063 if failed:
40634064 return {"status": "failed", "why": failed}
4065 if "cancelled" in states:
4066 return {"status": "failed", "why": "cancelled"}
40644067 return {"status": "none"}
40654068
40664069
……@@ -4269,6 +4272,131 @@ def web_download():
42694272 return redirect(back, code=303)
42704273
42714274
4275def _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
4281def _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
4288def _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
4298def _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
4345def _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
4351def _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")
4380def 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"])
4389def 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
42724400# The sources this addon knows about. Declared here, at the end, so each entry
42734401# can point at the resolver it uses. Dead and unusable sources are RECORDED
42744402# rather than deleted: the next person to notice that vidlink serves progressive
test_web_downloads.py +158 -0
……@@ -0,0 +1,158 @@
1"""The download manager page (labs#13): what is running, and what happened.
2
3Reads 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
5only, like every other action on this service.
6
7Run: .venv/bin/python3 -m pytest test_web_downloads.py -q
8"""
9
10import json
11import os
12import time
13
14import pytest
15
16import addon
17
18
19@pytest.fixture(autouse=True)
20def 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
32def client():
33 return addon.app.test_client()
34
35
36def _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
50def _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
56def 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
63def 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 &lt;i&gt;x&lt;/i&gt;" 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
73def 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
80def 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
86def 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
96def 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
111def 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
119def 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
128def 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
135def 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
140def 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
147def test_every_page_links_to_the_manager(client):
148 assert "href='/web/downloads'" in client.get("/").get_data(as_text=True)
149
150
151def 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