OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 82da9db

Show what was fetched, and let it be re-fetched (0.14.0)
Strange New Worlds S04E04 sat in the library as a fifth of an episode for five days. Every number needed to see that was already on the machine -- what is on disk, what TMDB says it should be, whether the copy was ever measured against its own source -- and none of them were anywhere a person could look. It was found by watching it. /library is those numbers. /library/audit.json is the same rows for scripting. Scope is the ingest queue rather than the whole volume: those are the copies this addon fetched and the only ones the button can act on, and offering to re-download the rest of /mnt/usb would be offering something we cannot do. It is a page rather than a row in Kodi for two reasons that have both cost time before: Jacktook caches its source list for 24h, so a row can be a day stale, and the client is Kore, which cannot drive Kodi's own UI. A LAN page is answered fresh from the same phone. "Unknown" is kept distinct from "ok". A copy present and playable but with no runtime to measure against has not been checked, and folding it into "ok" would claim a verification that never happened. The button needs force, because _run_ingest returns "done" for anything already held -- correct for the automatic path, useless for a human saying the copy is wrong. Force also drops the verification record: that record only ever attested the copy matched the source it came from, which is not the question being raised, and it must not survive to vouch for a file being replaced. POST only. Kodi issues speculative GETs all over this service -- the parent directory scan on every play is already a known 404 -- and starting a download must never be something a stray fetch can do. Immediately useful on the real data: it shows S04E04 repaired at 98% and carrying the source tag, S04E05 and Furious S01E07 still short, and three failed ingests that had left nothing behind and that nobody had noticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
82da9dbca5bb8f842b3bf5f84cf21bba19f0ef19
by plankatron <fleeky@gmail.com> · 2026-08-31T14:44:35.000Z

Changed files (2) +378 -6

addon.py +184 -6
……@@ -4,6 +4,7 @@ with a vsembed.ru/CloudStream Pro fallback (currently broken — Cloudflare Turn
44The fmovies path runs node + Playwright via subprocess to handle the JS-driven extraction.
55"""
66
7import html
78import json
89import logging
910import os
……@@ -595,7 +596,7 @@ TV_GENRES = [
595596
596597MANIFEST = {
597598 "id": "org.tabertron.vsembed",
598 "version": "0.13.3",
599 "version": "0.14.0",
599600 "name": "Tabertron VsEmbed",
600601 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
601602 "types": ["movie", "series"],
……@@ -1464,6 +1465,23 @@ def _record_verified(path: str, seconds: float, expected: float) -> None:
14641465 log.warning("verified store save failed: %s", exc)
14651466
14661467
1468def _forget_verified(path: str) -> None:
1469 """Drop a copy's verification record.
1470
1471 Only ever called for a FORCED re-ingest. The record attests that the file
1472 matched the source it came from, which is not the question a human asking
1473 for a re-download is raising, so it must not be allowed to veto them.
1474 """
1475 try:
1476 store = _load_verified()
1477 if store.pop(os.path.abspath(path), None) is None:
1478 return
1479 with open(LIBRARY_VERIFIED_FILE, "w") as f:
1480 json.dump(store, f)
1481 except Exception as exc:
1482 log.warning("verified store update failed: %s", exc)
1483
1484
14671485def _cached_media_duration(path: str) -> Optional[float]:
14681486 """_media_duration, keyed on the file's identity rather than its name.
14691487
……@@ -1657,7 +1675,7 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None:
16571675
16581676
16591677def _run_ingest(kind: str, ident: str, season: int, episode: int,
1660 height: int) -> str:
1678 height: int, force: bool = False) -> str:
16611679 """Fetch one title into the library. Synchronous; returns the final state.
16621680
16631681 Split out from the thread so the orchestration is testable without one, and
……@@ -1681,7 +1699,14 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int,
16811699 return f"failed: cannot identify {ident}"
16821700 dest = _library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
16831701 season, episode, meta["episode_title"])
1684 if _library_has(kind, ident, season, episode):
1702 if force:
1703 # A human is asserting the copy is wrong in a way no measurement can
1704 # see: the wrong episode, the wrong season, unwatchable audio. Its
1705 # verification record only ever attested that it matched the source it
1706 # came from, so it does not get to veto them.
1707 _forget_verified(dest)
1708 log.info("ingest: forced re-fetch of %s", os.path.basename(dest))
1709 elif _library_has(kind, ident, season, episode):
16851710 log.info("ingest: library already holds %s", os.path.basename(dest))
16861711 return "done"
16871712 url = _resolve_vidcore(kind, ident, season, episode)
……@@ -1700,7 +1725,8 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int,
17001725 return "done"
17011726
17021727
1703def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int) -> str:
1728def _start_mp4_job(kind: str, ident: str, season: int, episode: int,
1729 height: int, force: bool = False) -> str:
17041730 """Kick off an ingest in the background. Returns the job state.
17051731
17061732 The job holds no reference to any playback session, so pausing or stopping
……@@ -1719,7 +1745,7 @@ def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int
17191745 def run() -> None:
17201746 acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT)
17211747 try:
1722 state = (_run_ingest(kind, ident, season, episode, height)
1748 state = (_run_ingest(kind, ident, season, episode, height, force)
17231749 if acquired else "failed: remux slots busy")
17241750 except Exception as exc: # never leave a job "running"
17251751 log.warning("ingest crashed for %s: %s", key, exc)
……@@ -2265,11 +2291,163 @@ def meta(ctype: str, meta_id: str):
22652291 return jsonify({"meta": meta_obj})
22662292
22672293
2294# ------------------------------ library audit -------------------------------
2295# The truncation this exists for was invisible for five days and was found by
2296# watching an episode, not by any check. Every number needed to spot it was
2297# already here: what is on disk, what TMDB says it should be, and whether the
2298# copy was measured against its own source when it was written.
2299#
2300# Scope is what WE fetched -- the ingest queue -- not the whole library. Those
2301# are the copies this addon is answerable for and the only ones the button can
2302# act on. The rest of /mnt/usb arrived by other means, and offering to
2303# re-download it would be offering something we cannot do.
2304#
2305# This is a page rather than a row in Kodi because Jacktook caches its source
2306# list for 24h, so a row can be a day stale, and because the client is Kore,
2307# which cannot drive Kodi's own UI. A LAN page is answered fresh, every time,
2308# from the same phone.
2309
2310AUDIT_OK, AUDIT_SHORT, AUDIT_MISSING, AUDIT_UNKNOWN = (
2311 "ok", "short", "missing", "unknown")
2312
2313
2314def _audit_rows() -> list[dict]:
2315 """One row per title this addon has ever fetched, newest first."""
2316 verified = _load_verified()
2317 rows = []
2318 for key, entry in _load_job_queue().items():
2319 args = entry.get("args") or []
2320 if len(args) < 5:
2321 continue # pre-resume entry; cannot be re-run
2322 kind, ident, season, episode, height = args[:5]
2323 meta = _library_meta(kind, ident, int(season), int(episode))
2324 row = {"key": key, "kind": kind, "ident": ident,
2325 "season": int(season), "episode": int(episode),
2326 "height": int(height), "state": entry.get("state") or "",
2327 "ts": entry.get("ts") or 0,
2328 "title": meta["title"] if meta else ident,
2329 "label": (f"S{int(season):02d}E{int(episode):02d}"
2330 if kind == "series" else "movie")}
2331 if not meta:
2332 row.update(status=AUDIT_UNKNOWN, seconds=None, expected=None,
2333 pct=None, verified=False, path=None)
2334 rows.append(row)
2335 continue
2336 path = _library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
2337 int(season), int(episode), meta["episode_title"])
2338 row["path"] = path
2339 row["verified"] = os.path.abspath(path) in verified
2340 exists = os.path.exists(path) and os.path.getsize(path) > 0
2341 row["seconds"] = _cached_media_duration(path) if exists else None
2342 try:
2343 row["expected"] = float(meta.get("runtime")) * 60
2344 except (TypeError, ValueError):
2345 row["expected"] = None
2346 if not exists:
2347 row["status"], row["pct"] = AUDIT_MISSING, None
2348 elif not row["expected"] or row["seconds"] is None:
2349 # Present and playable, but nothing to measure it against. Reported
2350 # as its own state rather than folded into "ok" -- an unchecked copy
2351 # and a checked one are not the same claim.
2352 row["status"], row["pct"] = AUDIT_UNKNOWN, None
2353 else:
2354 row["pct"] = row["seconds"] / row["expected"] * 100
2355 row["status"] = (AUDIT_OK
2356 if _copy_is_complete(path, meta) else AUDIT_SHORT)
2357 rows.append(row)
2358 rows.sort(key=lambda r: r["ts"], reverse=True)
2359 return rows
2360
2361
2362_AUDIT_CSS = """
2363body{font:14px/1.5 system-ui,sans-serif;margin:1.5rem;color:#222}
2364table{border-collapse:collapse;width:100%;max-width:60rem}
2365th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #ddd}
2366th{font-weight:600;border-bottom:2px solid #999}
2367tr.short{background:#fdecea}
2368tr.missing,tr.unknown{background:#fff8e1}
2369.tag{font-size:.75rem;padding:.1rem .4rem;border-radius:.2rem;background:#eee}
2370.tag.short{background:#d32f2f;color:#fff}
2371.tag.ok{background:#2e7d32;color:#fff}
2372button{padding:.3rem .7rem;cursor:pointer}
2373td.num{text-align:right;font-variant-numeric:tabular-nums}
2374"""
2375
2376
2377def _audit_html(rows: list[dict]) -> str:
2378 def mins(seconds):
2379 return "—" if not seconds else f"{seconds / 60:.0f}m"
2380
2381 body = []
2382 short = sum(1 for r in rows if r["status"] == AUDIT_SHORT)
2383 body.append(f"<h1>Library ({len(rows)} fetched"
2384 + (f", <b>{short} incomplete</b>" if short else "") + ")</h1>")
2385 body.append("<table><tr><th>Title</th><th></th><th>On disk</th>"
2386 "<th>Expected</th><th></th><th>Checked</th><th></th></tr>")
2387 for r in rows:
2388 pct = "—" if r["pct"] is None else f"{r['pct']:.0f}%"
2389 body.append(
2390 f"<tr class={r['status']}>"
2391 f"<td>{html.escape(str(r['title']))}</td>"
2392 f"<td>{html.escape(r['label'])}</td>"
2393 f"<td class=num>{mins(r['seconds'])}</td>"
2394 f"<td class=num>{mins(r['expected'])}</td>"
2395 f"<td class=num><span class='tag {r['status']}'>{pct if r['status'] != AUDIT_MISSING else 'missing'}</span></td>"
2396 f"<td>{'source' if r['verified'] else '—'}</td>"
2397 "<td><form method=post action=/library/reingest>"
2398 f"<input type=hidden name=kind value='{html.escape(r['kind'])}'>"
2399 f"<input type=hidden name=ident value='{html.escape(r['ident'])}'>"
2400 f"<input type=hidden name=season value='{r['season']}'>"
2401 f"<input type=hidden name=episode value='{r['episode']}'>"
2402 f"<input type=hidden name=height value='{r['height']}'>"
2403 "<button name=force value=1>Re-download</button>"
2404 "</form></td></tr>")
2405 body.append("</table>")
2406 return (f"<!doctype html><meta charset=utf-8>"
2407 f"<meta name=viewport content='width=device-width,initial-scale=1'>"
2408 f"<title>vsembed library</title><style>{_AUDIT_CSS}</style>"
2409 + "".join(body))
2410
2411
2412@app.route("/library")
2413def library_audit():
2414 return Response(_audit_html(_audit_rows()), mimetype="text/html")
2415
2416
2417@app.route("/library/audit.json")
2418def library_audit_json():
2419 return jsonify(rows=_audit_rows())
2420
2421
2422@app.route("/library/reingest", methods=["POST"])
2423def library_reingest():
2424 """POST only, deliberately. Kodi issues speculative GETs all over this
2425 service -- the parent-directory scan on every play is already a known 404
2426 -- and a re-download must never be something a stray fetch can start.
2427 """
2428 kind = request.form.get("kind") or ""
2429 ident = request.form.get("ident") or ""
2430 if kind not in ("movie", "series") or not ident:
2431 abort(400)
2432 try:
2433 season = int(request.form.get("season") or 0)
2434 episode = int(request.form.get("episode") or 0)
2435 height = int(request.form.get("height") or VIDCORE_MAX_HEIGHT)
2436 except ValueError:
2437 abort(400)
2438 state = _start_mp4_job(kind, ident, season, episode, height,
2439 force=bool(request.form.get("force")))
2440 log.info("library: re-ingest requested for %s:%s:%s:%s -> %s",
2441 kind, ident, season, episode, state)
2442 return redirect("/library", code=303)
2443
2444
22682445@app.route("/")
22692446def index():
22702447 return (
22712448 "Tabertron VsEmbed Stremio addon.<br>"
2272 "Install URL: <code>/manifest.json</code>"
2449 "Install URL: <code>/manifest.json</code><br>"
2450 "Fetched copies: <a href='/library'>/library</a>"
22732451 )
22742452
22752453
test_library_audit.py +194 -0
……@@ -0,0 +1,194 @@
1"""The library audit: is what we fetched actually all there?
2
3Strange New Worlds S04E04 sat in the library as 10.6 minutes of a 51-minute
4episode for five days. Nothing surfaced it -- it was found by watching it. Every
5number needed to catch it was already on the machine, so this page is mostly an
6admission that having the numbers is not the same as showing them.
7
8Scope is the ingest queue, not the whole library: those are the copies this
9addon fetched, and the only ones the button can act on.
10
11Run: .venv/bin/python3 -m pytest test_library_audit.py -q
12"""
13
14import json
15import os
16
17import pytest
18
19import addon
20
21
22@pytest.fixture(autouse=True)
23def isolated(tmp_path, monkeypatch):
24 """Every store this touches lives beside addon.py in the deployed
25 checkout. A test that read the real ingest queue would reach TMDB for
26 seventeen real titles; one that wrote the real verified store would mark
27 production files trustworthy."""
28 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path))
29 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE",
30 os.path.join(str(tmp_path), "queue.json"))
31 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE",
32 os.path.join(str(tmp_path), "verified.json"))
33 monkeypatch.setattr(addon, "_duration_cache", {})
34 monkeypatch.setattr(addon, "_library_meta_cache", {})
35 monkeypatch.setattr(addon, "_mp4_jobs", {})
36 yield
37
38
39@pytest.fixture
40def library(monkeypatch, tmp_path):
41 """One fetched episode, 50 minutes per TMDB, whose length is settable."""
42 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: {
43 "tv/1": {"name": "Show", "first_air_date": "2020-01-01"},
44 "tv/1/season/1/episode/1": {"name": "Pilot", "runtime": 50},
45 }[path])
46 with open(addon.INGEST_QUEUE_FILE, "w") as f:
47 json.dump({"series:tmdb:1:1:1:1080": {
48 "state": "done", "args": ["series", "tmdb:1", 1, 1, 1080],
49 "ts": 100.0}}, f)
50 meta = addon._library_meta("series", "tmdb:1", 1, 1)
51 path = addon._library_path("series", meta["title"], meta["year"],
52 meta["tmdb_id"], 1, 1, meta["episode_title"])
53 os.makedirs(os.path.dirname(path), exist_ok=True)
54 with open(path, "wb") as f:
55 f.write(b"x" * 1024)
56 seconds = {"value": 3000.0}
57 monkeypatch.setattr(addon, "_media_duration", lambda p: seconds["value"])
58 return path, seconds
59
60
61@pytest.fixture
62def client():
63 return addon.app.test_client()
64
65
66# --- what the audit reports -------------------------------------------------
67
68def test_a_complete_copy_reads_ok(library):
69 assert addon._audit_rows()[0]["status"] == addon.AUDIT_OK
70
71
72def test_a_short_copy_is_flagged(library):
73 _, seconds = library
74 seconds["value"] = 636.7
75 row = addon._audit_rows()[0]
76 assert row["status"] == addon.AUDIT_SHORT
77 assert round(row["pct"]) == 21
78
79
80def test_a_copy_that_is_gone_reads_missing(library):
81 path, _ = library
82 os.remove(path)
83 assert addon._audit_rows()[0]["status"] == addon.AUDIT_MISSING
84
85
86def test_a_copy_with_nothing_to_measure_against_is_not_called_ok(library, monkeypatch):
87 """Present and playable, but unchecked. Folding that into "ok" would be
88 claiming a verification that never happened."""
89 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: {
90 "tv/1": {"name": "Show", "first_air_date": "2020-01-01"},
91 "tv/1/season/1/episode/1": {"name": "Pilot"},
92 }[path])
93 monkeypatch.setattr(addon, "_library_meta_cache", {})
94 assert addon._audit_rows()[0]["status"] == addon.AUDIT_UNKNOWN
95
96
97def test_a_source_verified_copy_says_so(library):
98 path, _ = library
99 assert addon._audit_rows()[0]["verified"] is False
100 addon._record_verified(path, 3000.0, 3000.0)
101 assert addon._audit_rows()[0]["verified"] is True
102
103
104def test_a_queue_entry_with_no_arguments_is_skipped(library):
105 """Pre-resume entries cannot be re-run, so offering a button would lie."""
106 with open(addon.INGEST_QUEUE_FILE, "w") as f:
107 json.dump({"old:key": {"state": "done", "ts": 1.0}}, f)
108 assert addon._audit_rows() == []
109
110
111# --- the page ---------------------------------------------------------------
112
113def test_the_page_renders_and_counts_the_bad_ones(library, client):
114 _, seconds = library
115 seconds["value"] = 636.7
116 body = client.get("/library").get_data(as_text=True)
117 assert "1 incomplete" in body and "Re-download" in body
118
119
120def test_a_title_cannot_inject_markup(library, client, monkeypatch):
121 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: {
122 "tv/1": {"name": "<script>x</script>", "first_air_date": "2020"},
123 "tv/1/season/1/episode/1": {"name": "Pilot", "runtime": 50},
124 }[path])
125 monkeypatch.setattr(addon, "_library_meta_cache", {})
126 body = client.get("/library").get_data(as_text=True)
127 assert "<script>x</script>" not in body
128
129
130def test_the_json_endpoint_carries_the_same_verdicts(library, client):
131 _, seconds = library
132 seconds["value"] = 636.7
133 rows = client.get("/library/audit.json").get_json()["rows"]
134 assert rows[0]["status"] == addon.AUDIT_SHORT
135
136
137# --- the button -------------------------------------------------------------
138
139def test_re_ingest_is_not_reachable_by_a_get(client):
140 """Kodi issues speculative GETs all over this service -- the parent-directory
141 scan on every play is already a known 404. A re-download must never be
142 something a stray fetch can start."""
143 assert client.get("/library/reingest").status_code == 405
144
145
146def test_re_ingest_starts_a_forced_job(library, client, monkeypatch):
147 calls = []
148 monkeypatch.setattr(addon, "_start_mp4_job",
149 lambda *a, **k: (calls.append((a, k)), "running")[1])
150 r = client.post("/library/reingest", data={
151 "kind": "series", "ident": "tmdb:1", "season": "1",
152 "episode": "1", "height": "1080", "force": "1"})
153 assert r.status_code == 303
154 assert calls[0][0] == ("series", "tmdb:1", 1, 1, 1080)
155 assert calls[0][1]["force"] is True
156
157
158def test_re_ingest_rejects_a_bad_kind(client):
159 r = client.post("/library/reingest", data={"kind": "../x", "ident": "tmdb:1"})
160 assert r.status_code == 400
161
162
163# --- forcing ----------------------------------------------------------------
164
165def test_a_forced_ingest_ignores_a_copy_already_held(library, monkeypatch):
166 """Without force, _run_ingest returns "done" for a file already on disk --
167 which is right for the automatic path and useless for a human saying the
168 copy is wrong."""
169 ran = []
170 monkeypatch.setattr(addon, "ingest_source", lambda: object())
171 monkeypatch.setattr(addon, "_resolve_vidcore",
172 lambda *a, **k: "http://cdn/master.m3u8")
173 monkeypatch.setattr(addon, "_cap_variant", lambda url, h: url)
174 monkeypatch.setattr(addon, "_remux_to_mp4",
175 lambda url, dest: ran.append(dest))
176 assert addon._run_ingest("series", "tmdb:1", 1, 1, 1080) == "done"
177 assert ran == []
178 addon._run_ingest("series", "tmdb:1", 1, 1, 1080, force=True)
179 assert len(ran) == 1
180
181
182def test_forcing_drops_the_source_verification(library, monkeypatch):
183 """The record attests the copy matched the source it came from. That is not
184 the question a human asking for a re-download is raising, so it must not
185 veto them -- and must not survive to vouch for a file being replaced."""
186 path, _ = library
187 addon._record_verified(path, 3000.0, 3000.0)
188 monkeypatch.setattr(addon, "ingest_source", lambda: object())
189 monkeypatch.setattr(addon, "_resolve_vidcore",
190 lambda *a, **k: "http://cdn/master.m3u8")
191 monkeypatch.setattr(addon, "_cap_variant", lambda url, h: url)
192 monkeypatch.setattr(addon, "_remux_to_mp4", lambda url, dest: None)
193 addon._run_ingest("series", "tmdb:1", 1, 1, 1080, force=True)
194 assert addon._load_verified() == {}