OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 6a20bc9

Reject a source per file + name the source on every web action (0.29.0)
2026-10-14, VisionQuest S01E01: vidcore's copy has one audio track, tagged eng, spoken French (E02 from vidcore is English). The tag lies, so nothing here can detect it. Delete + Download fetched the same copy again because vidcore is always tried first; HdHub had a correct file (x264, eng track). - source_rejects.json {library path: [source ids]}: a human verdict per FILE, read by _run_ingest, so web download, season fetch, Kodi play-and-keep and resume all honour it. Atomic write; an unreadable file rejects nothing and is never overwritten (the 2026-08-31 queue-loss shape). - /web/replace (watch page "Bad copy — replace from X"): rejects the source the copy came from and forces a re-fetch; the old copy stays until the new one is whole. Same scope rule as Delete (only copies we fetched). - /web/source/{reject,allow}; "Allow again" shown beside a rejection. - Download says "from vidcore (flixlab), else HdHub"; running says "downloading from ..."; held says "in library · <source>". conftest redirects SOURCE_REJECTS_FILE; test_suite_isolation asserts it. test_source_reject.py (21). Suite 512 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6a20bc9127cd66f81fbd9dbb909aa86c743b6b6e
by plankatron <fleeky@gmail.com> · 2026-10-14T17:12:08.000Z

Changed files (4) +482 -8

addon.py +240 -8
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.28.1",
622 "version": "0.29.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -1347,6 +1347,74 @@ def _record_attempt(key: str, info: dict, state: str, started: float) -> None:
13471347 "took": round(time.time() - started, 1), "dest": dest,
13481348 })
13491349
1350# A human verdict that one source's copy of one FILE is wrong, in a way no
1351# measurement here can see. 2026-10-14, VisionQuest S01E01: vidcore's only audio
1352# track is tagged `eng` and is French; re-fetching took the same copy again,
1353# because vidcore is always tried first. Keyed by library path, like the dest
1354# lock, because a Kodi `tt…` job and a web `tmdb:…` job name the same file.
1355# Read by _run_ingest, so every way into the library honours it.
1356SOURCE_REJECTS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
1357 "source_rejects.json")
1358_rejects_guard = threading.Lock()
1359
1360
1361def _read_rejects() -> dict:
1362 """RAISES on an unreadable file -- the writer's read (see _read_job_queue)."""
1363 try:
1364 with open(SOURCE_REJECTS_FILE) as f:
1365 data = json.load(f)
1366 except FileNotFoundError:
1367 return {}
1368 if not isinstance(data, dict):
1369 raise ValueError("source rejects is not a JSON object")
1370 return data
1371
1372
1373def _rejected_sources(dest: str) -> set:
1374 """Never raises. Unreadable reads as nothing rejected: a record we cannot
1375 read must not become a way for a working source to stop."""
1376 try:
1377 return set(_read_rejects().get(os.path.abspath(dest)) or [])
1378 except Exception as exc:
1379 log.warning("source rejects unreadable (%s) — rejecting nothing", exc)
1380 return set()
1381
1382
1383def _change_rejects(dest: str, sid: str, rejected: bool) -> None:
1384 """Never raises. An unreadable file is left exactly as it is."""
1385 try:
1386 with _rejects_guard:
1387 data = _read_rejects()
1388 key = os.path.abspath(dest)
1389 ids = set(data.get(key) or [])
1390 ids = ids | {sid} if rejected else ids - {sid}
1391 if ids:
1392 data[key] = sorted(ids)
1393 else:
1394 data.pop(key, None)
1395 tmp = SOURCE_REJECTS_FILE + ".tmp"
1396 with open(tmp, "w") as f:
1397 json.dump(data, f, indent=1)
1398 os.replace(tmp, SOURCE_REJECTS_FILE)
1399 log.info("source %s %s for %s", sid,
1400 "rejected" if rejected else "allowed again", os.path.basename(dest))
1401 except Exception as exc:
1402 log.warning("source rejects not saved: %s", exc)
1403
1404
1405def _reject_source(dest: str, sid: str) -> None:
1406 _change_rejects(dest, sid, True)
1407
1408
1409def _allow_source(dest: str, sid: str) -> None:
1410 _change_rejects(dest, sid, False)
1411
1412
1413def _ingest_order(dest: str) -> list[str]:
1414 """Source ids that may fill this file, in the order they will be tried."""
1415 rejected = _rejected_sources(dest)
1416 return [s.id for s in ingest_sources() if s.id not in rejected]
1417
13501418# Job state was in-memory only, so a restart lost every intent: a copy that was
13511419# mid-flight never resumed, and since a partial writes to `<dest>.part` while the
13521420# row keys off the finished file, nothing surfaced the loss. Rolling forward
……@@ -2544,16 +2612,23 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int,
25442612 # vidcore first: it is asked by TMDB id, so what it returns IS this title.
25452613 # HdHub is the fallback because its answer is matched by filename -- see
25462614 # ingest_sources(). A failure of either kind (no resolve, or a resolve onto
2547 # a CDN that refuses the bytes, as on 2026-09-28) falls through.
2615 # a CDN that refuses the bytes, as on 2026-09-28) falls through. A source a
2616 # human rejected for this file is skipped (SOURCE_REJECTS_FILE).
2617 order = _ingest_order(dest)
2618 if not order:
2619 return "failed: every source is rejected for this title"
25482620 started = time.time()
25492621 failure = None
25502622 _job_update(key, phase="resolving", dest=dest)
2551 url = _resolve_vidcore(kind, ident, season, episode)
2623 url = (_resolve_vidcore(kind, ident, season, episode)
2624 if "vidcore" in order else None)
25522625 # The resolve is a browser subprocess and is let run to its end; Cancel is
25532626 # honoured the moment it returns, before any byte is fetched.
25542627 if _cancel_requested():
25552628 return "cancelled"
2556 if url:
2629 if "vidcore" not in order:
2630 failure = "failed: vidcore rejected for this title"
2631 elif url:
25572632 variant = _cap_variant(url, height)
25582633 # The preview plays exactly what is being copied (labs#13).
25592634 picked = next(((h, bw) for h, bw, uri, _ in _variants(url)
……@@ -2576,6 +2651,8 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int,
25762651 if failure:
25772652 if _cancel_requested():
25782653 return "cancelled"
2654 if "hdhub" not in order:
2655 return f"{failure}; hdhub: rejected for this title"
25792656 started = time.time()
25802657 _job_update(key, phase="downloading", source="hdhub",
25812658 downloading_since=started)
……@@ -4372,13 +4449,15 @@ def _web_state(kind: str, tmdb: str, season: int = 0,
43724449 """
43734450 ident = f"tmdb:{tmdb}"
43744451 if _library_has(kind, ident, season, episode):
4375 return {"status": "held"}
4452 dest = _web_dest(kind, tmdb, season, episode)
4453 return {"status": "held", "source": _source_of(dest) if dest else None}
43764454 states = [_mp4_jobs.get(_mp4_key(kind, ident, season, episode, h))
43774455 for h in VIDCORE_HEIGHTS]
43784456 if "running" in states:
43794457 key = next(_mp4_key(kind, ident, season, episode, h)
43804458 for h, st in zip(VIDCORE_HEIGHTS, states) if st == "running")
43814459 return {"status": "running", "key": key,
4460 "source": (_job_info.get(key) or {}).get("source"),
43824461 "bytes": _ingest_progress(kind, ident, season, episode)}
43834462 failed = next((s for s in states if s and s.startswith("failed")), None)
43844463 if failed:
……@@ -4388,15 +4467,33 @@ def _web_state(kind: str, tmdb: str, season: int = 0,
43884467 return {"status": "none"}
43894468
43904469
4470def _source_name(sid: Optional[str]) -> str:
4471 src = get_source(sid or "")
4472 return src.name if src else (sid or "")
4473
4474
4475def _web_dest(kind: str, tmdb: str, season: int, episode: int) -> Optional[str]:
4476 """The library path for one file, or None when TMDB cannot name it."""
4477 meta = _library_meta(kind, f"tmdb:{tmdb}", season, episode)
4478 if not meta:
4479 return None
4480 return os.path.abspath(_library_path(kind, meta["title"], meta["year"],
4481 meta["tmdb_id"], season, episode,
4482 meta["episode_title"]))
4483
4484
43914485def _web_state_html(state: dict, watch: Optional[str] = None) -> str:
43924486 s = state["status"]
43934487 if s == "held":
4394 return ("<span class='st held'>in library</span>"
4488 src = _source_name(state.get("source"))
4489 return (f"<span class='st held'>in library{' · ' + html.escape(src) if src else ''}</span>"
43954490 + (f" <a class=watch href='{watch}'>Watch</a>" if watch else ""))
43964491 if s == "running":
43974492 n = state.get("bytes")
43984493 got = f" · {n / 1e6:.0f} MB" if n else " · starting"
4399 return (f"<span class='st running'>downloading{got}</span>"
4494 src = _source_name(state.get("source"))
4495 frm = f" from {html.escape(src)}" if src else ""
4496 return (f"<span class='st running'>downloading{frm}{got}</span>"
44004497 + _preview_link(state.get("key")))
44014498 if s == "failed":
44024499 return f"<span class='st failed'>{html.escape(state['why'])}</span>"
……@@ -4413,7 +4510,38 @@ def _web_download_form(kind: str, tmdb: str, season: int = 0, episode: int = 0,
44134510 f"<input type=hidden name='episode' value='{episode}'>"
44144511 f"<input type=hidden name='scope' value='{scope}'>"
44154512 f"<select name=height aria-label=Quality>{heights}</select>"
4416 f"<button class=go>{label}</button></form>")
4513 f"<button class=go>{label}</button></form>"
4514 + _web_source_line(kind, tmdb, season, episode))
4515
4516
4517def _web_source_line(kind: str, tmdb: str, season: int, episode: int) -> str:
4518 """Where a Download will fetch from, and what a human has ruled out.
4519
4520 A season button has no single file, so it shows the default order; each
4521 episode it fetches still honours its own rejections in _run_ingest.
4522 """
4523 dest = None if kind == "series" and not episode else _web_dest(
4524 kind, tmdb, season, episode)
4525 order = _ingest_order(dest) if dest else [s.id for s in ingest_sources()]
4526 names = [html.escape(_source_name(sid)) for sid in order]
4527 where = ("from " + ", else ".join(names)) if names else "no source left"
4528 out = f" <span class=mute>{where}</span>"
4529 for sid in sorted(_rejected_sources(dest)) if dest else []:
4530 out += (f" <span class=mute>· {html.escape(_source_name(sid))} rejected</span>"
4531 + _web_source_form("allow", kind, tmdb, season, episode, sid,
4532 "Allow again"))
4533 return out
4534
4535
4536def _web_source_form(action: str, kind: str, tmdb: str, season: int,
4537 episode: int, sid: str, label: str) -> str:
4538 return (f"<form class=dl method=post action='/web/source/{action}'>"
4539 f"<input type=hidden name='kind' value='{kind}'>"
4540 f"<input type=hidden name='tmdb' value='{tmdb}'>"
4541 f"<input type=hidden name='season' value='{season}'>"
4542 f"<input type=hidden name='episode' value='{episode}'>"
4543 f"<input type=hidden name='source' value='{html.escape(sid)}'>"
4544 f"<button>{label}</button></form>")
44174545
44184546
44194547def _web_low_space() -> str:
……@@ -4908,6 +5036,7 @@ def _web_watch(kind: str, tmdb: str, season: int = 0, episode: int = 0):
49085036 f"<video controls preload=metadata playsinline src='{file_url}'"
49095037 " style='width:100%;max-height:70vh;background:#000'></video>"
49105038 "<h2>What this copy is</h2>" + _quality_html(path, meta)
5039 + _web_replace_form(kind, tmdb, season, episode, path)
49115040 + _web_delete_form(kind, tmdb, season, episode))
49125041 return _web_page(f"{title} {sub} — watch", body)
49135042
……@@ -5102,6 +5231,109 @@ def web_delete():
51025231 return redirect(back, code=303)
51035232
51045233
5234# ---------------------------------- replace ---------------------------------
5235# A copy that is wrong in a way only a person notices (2026-10-14: VisionQuest
5236# S01E01's audio tagged eng, spoken French). Delete + Download fetched the same
5237# copy again, because vidcore is always first. Replace rejects the source the
5238# copy came from, for this file, and forces a re-fetch from the next one. The
5239# forced job writes a .part and renames, so the old copy stays until the new
5240# one is whole -- and stays for good if no other source has it.
5241
5242def _web_form_target(f) -> tuple[str, str, int, int]:
5243 kind, tmdb = f.get("kind") or "", f.get("tmdb") or ""
5244 if kind not in ("movie", "series") or not tmdb.isdigit():
5245 abort(400)
5246 try:
5247 season = int(f.get("season") or 0)
5248 episode = int(f.get("episode") or 0)
5249 except ValueError:
5250 abort(400)
5251 if kind == "movie":
5252 season = episode = 0
5253 return kind, tmdb, season, episode
5254
5255
5256def _web_back(kind: str, tmdb: str, season: int) -> str:
5257 return f"/web/title/{kind}/{tmdb}" + (f"?s={season}" if kind == "series" else "")
5258
5259
5260def _replace_copy(kind: str, tmdb: str, season: int, episode: int,
5261 height: int) -> tuple[int, str]:
5262 """(http status, what happened). 200 when the re-fetch was started."""
5263 dest = _web_dest(kind, tmdb, season, episode)
5264 if not dest or not os.path.exists(dest):
5265 return 404, "no copy on disk"
5266 # The forced job overwrites the file: the same scope rule as Delete.
5267 if dest not in _fetched_dests():
5268 return 404, "not a copy this addon fetched"
5269 source = _source_of(dest)
5270 if not source:
5271 return 409, "where this copy came from was not recorded"
5272 if not [sid for sid in _ingest_order(dest) if sid != source]:
5273 return 409, f"no source other than {_source_name(source)} is left"
5274 _reject_source(dest, source)
5275 state = _start_mp4_job(kind, f"tmdb:{tmdb}", season, episode, height,
5276 force=True)
5277 return 200, f"{_source_name(source)} rejected, re-fetch {state}"
5278
5279
5280@app.route("/web/replace", methods=["POST"])
5281def web_replace():
5282 """POST only, like every action here."""
5283 kind, tmdb, season, episode = _web_form_target(request.form)
5284 try:
5285 height = int(request.form.get("height") or VIDCORE_MAX_HEIGHT)
5286 except ValueError:
5287 abort(400)
5288 if height not in VIDCORE_HEIGHTS:
5289 abort(400)
5290 status, what = _replace_copy(kind, tmdb, season, episode, height)
5291 log.info("web: replace %s:%s:%s:%s -> %s %s",
5292 kind, tmdb, season, episode, status, what)
5293 if status != 200:
5294 return _web_page("Not replaced", f"<p class=warn>{html.escape(what)}</p>",
5295 status=status)
5296 return redirect(_web_back(kind, tmdb, season), code=303)
5297
5298
5299@app.route("/web/source/<action>", methods=["POST"])
5300def web_source(action: str):
5301 """Reject or allow again one source for one file."""
5302 if action not in ("reject", "allow"):
5303 abort(404)
5304 kind, tmdb, season, episode = _web_form_target(request.form)
5305 sid = request.form.get("source") or ""
5306 if sid not in SOURCES:
5307 abort(400)
5308 dest = _web_dest(kind, tmdb, season, episode)
5309 if not dest:
5310 abort(404)
5311 (_reject_source if action == "reject" else _allow_source)(dest, sid)
5312 return redirect(_web_back(kind, tmdb, season), code=303)
5313
5314
5315def _web_replace_form(kind: str, tmdb: str, season: int, episode: int,
5316 path: str) -> str:
5317 """'Bad copy' button, when there is somewhere else to get it from."""
5318 if os.path.abspath(path) not in _fetched_dests():
5319 return ""
5320 source = _source_of(path)
5321 others = [sid for sid in _ingest_order(path) if sid != source]
5322 if not source or not others:
5323 return ""
5324 prompt = json.dumps(f"Stop using {_source_name(source)} for this and fetch it "
5325 f"from {_source_name(others[0])}? The current copy stays "
5326 "until the new one is complete.")
5327 return ("<form class=dl method=post action='/web/replace' "
5328 f"onsubmit='return confirm({html.escape(prompt, quote=True)})'>"
5329 f"<input type=hidden name='kind' value='{kind}'>"
5330 f"<input type=hidden name='tmdb' value='{tmdb}'>"
5331 f"<input type=hidden name='season' value='{season}'>"
5332 f"<input type=hidden name='episode' value='{episode}'>"
5333 f"<button>Bad copy — replace from {html.escape(_source_name(others[0]))}"
5334 "</button></form>")
5335
5336
51055337@app.route("/web/watch/movie/<tmdb>")
51065338def web_watch_movie(tmdb: str):
51075339 return _web_watch("movie", tmdb)
conftest.py +2 -0
……@@ -28,6 +28,8 @@ def no_production_history(tmp_path, monkeypatch):
2828 monkeypatch.setattr(addon, "SUBTITLE_SYNC_FILE", str(tmp_path / "subtitle_sync.json"))
2929 monkeypatch.setattr(addon, "SUBS_ORIGINAL_DIR", str(tmp_path / "subs-original"))
3030 monkeypatch.setattr(addon, "_subsync_available", lambda: False)
31 # Per-file source rejections (2026-10-14) sit beside addon.py too.
32 monkeypatch.setattr(addon, "SOURCE_REJECTS_FILE", str(tmp_path / "source_rejects.json"))
3133 yield
3234
3335
test_source_reject.py +236 -0
……@@ -0,0 +1,236 @@
1"""A source can be rejected for one title, and the page says where a copy
2comes from.
3
42026-10-14, VisionQuest S01E01: vidcore's copy has ONE audio track, tagged
5`eng`, and it is French. E02 from the same source is English. Nothing in the
6stream can tell them apart -- the tag lies -- so the remux took it, the user
7heard it, deleted it, pressed Download again ... and got the same French copy,
8because vidcore is always tried first and a re-fetch had no way to skip it.
9HdHub had a correct file the whole time (1080p x264, separate eng track).
10
11So: a human verdict, recorded per FILE (a Kodi `tt…` job and a web `tmdb:…`
12job name the same destination), that every ingest path honours -- web
13download, season fetch, Kodi play-and-keep, restart resume all go through
14_run_ingest. And the user asked for the other half the same day: the page
15said "Download" and never which source it would download FROM.
16
17Run: .venv/bin/python3 -m pytest test_source_reject.py -q
18"""
19
20import json
21import os
22
23import pytest
24
25import addon
26from test_hdhub import hdhub, ingest, isolated, probe, row, tmdb # noqa: F401
27
28
29TONY = ("movie", "tmdb:1329016", 0, 0)
30
31
32@pytest.fixture(autouse=True)
33def rejects_file(tmp_path, monkeypatch):
34 monkeypatch.setattr(addon, "SOURCE_REJECTS_FILE", str(tmp_path / "rejects.json"))
35 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json"))
36 monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path / "mp4-cache"))
37 yield
38
39
40def _dest(kind="movie", ident="tmdb:1329016", season=0, episode=0):
41 meta = addon._library_meta(kind, ident, season, episode)
42 return addon._library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
43 season, episode, meta["episode_title"])
44
45
46# --- the ingest honours it --------------------------------------------------
47
48def test_a_rejected_vidcore_is_never_resolved(ingest, monkeypatch):
49 monkeypatch.setattr(addon, "_resolve_vidcore",
50 lambda *a, **k: pytest.fail("vidcore was rejected"))
51 addon._reject_source(_dest(), "vidcore")
52 assert addon._run_ingest(*TONY, 1080) == "done"
53 assert ingest, "HdHub must supply the copy"
54
55
56def test_every_source_rejected_fetches_nothing_and_says_why(ingest, monkeypatch):
57 monkeypatch.setattr(addon, "_resolve_vidcore",
58 lambda *a, **k: pytest.fail("vidcore was rejected"))
59 addon._reject_source(_dest(), "vidcore")
60 addon._reject_source(_dest(), "hdhub")
61 state = addon._run_ingest(*TONY, 1080)
62 assert state.startswith("failed") and "rejected" in state
63 assert ingest == []
64
65
66def test_a_rejected_hdhub_is_not_the_fallback(ingest, hdhub):
67 addon._reject_source(_dest(), "hdhub")
68 state = addon._run_ingest(*TONY, 1080)
69 assert state.startswith("failed: ffmpeg exit 8") and ingest == []
70 assert hdhub.calls == []
71
72
73def test_a_rejection_is_per_file_not_per_show(ingest, monkeypatch):
74 """Rejecting vidcore for one copy must not touch any other title."""
75 addon._reject_source("/somewhere/else.mp4", "vidcore")
76 tried = []
77 monkeypatch.setattr(addon, "_resolve_vidcore",
78 lambda *a, **k: tried.append(a) or None)
79 addon._run_ingest(*TONY, 1080)
80 assert tried
81
82
83def test_a_rejection_survives_a_restart(ingest, tmp_path):
84 addon._reject_source(_dest(), "vidcore")
85 data = json.loads((tmp_path / "rejects.json").read_text())
86 assert data == {os.path.abspath(_dest()): ["vidcore"]}
87 assert addon._rejected_sources(_dest()) == {"vidcore"}
88
89
90def test_allowing_again_undoes_it(ingest):
91 addon._reject_source(_dest(), "vidcore")
92 addon._allow_source(_dest(), "vidcore")
93 assert addon._rejected_sources(_dest()) == set()
94
95
96def test_an_unreadable_rejects_file_is_never_overwritten(ingest, tmp_path):
97 """Same shape as the 2026-08-31 queue loss: a writer that reads garbage as
98 'nothing' would persist nothing over every rejection on record."""
99 p = tmp_path / "rejects.json"
100 p.write_text("{not json")
101 addon._reject_source(_dest(), "vidcore")
102 assert p.read_text() == "{not json"
103
104
105def test_an_unreadable_rejects_file_rejects_nothing(ingest):
106 """A reader that cannot read must not stop a working source."""
107 open(addon.SOURCE_REJECTS_FILE, "w").write("{not json")
108 assert addon._rejected_sources(_dest()) == set()
109
110
111def test_the_ingest_order_skips_rejected_sources(ingest):
112 assert addon._ingest_order(_dest()) == ["vidcore", "hdhub"]
113 addon._reject_source(_dest(), "vidcore")
114 assert addon._ingest_order(_dest()) == ["hdhub"]
115
116
117# --- the web page ------------------------------------------------------------
118
119@pytest.fixture
120def web(ingest, monkeypatch):
121 started = []
122 monkeypatch.setattr(addon, "_start_mp4_job",
123 lambda *a, **k: started.append((a, k)) or "running")
124 monkeypatch.setattr(addon, "_tmdb_get", _web_tmdb(addon._tmdb_get))
125 c = addon.app.test_client()
126 c.started = started
127 return c
128
129
130def _web_tmdb(inner):
131 def fake(path, params=None):
132 if path == "movie/1329016/credits":
133 return {}
134 return inner(path, params)
135 return fake
136
137
138def _hold_fetched(source="vidcore"):
139 """A copy this addon fetched, from `source`, as history records it."""
140 dest = _dest()
141 os.makedirs(os.path.dirname(dest), exist_ok=True)
142 open(dest, "wb").write(b"x" * 1000)
143 addon._set_job_state("movie:tmdb:1329016:0:0:1080", "done",
144 args=("movie", "tmdb:1329016", 0, 0, 1080))
145 addon._append_history({"outcome": "done", "dest": dest, "source": source,
146 "kind": "movie", "ident": "tmdb:1329016"})
147 return dest
148
149
150def test_the_download_button_names_its_source(web):
151 page = web.get("/web/title/movie/1329016").get_data(as_text=True)
152 assert "from vidcore (flixlab), else HdHub" in page
153
154
155def test_after_a_rejection_the_button_names_the_other_source(web):
156 addon._reject_source(_dest(), "vidcore")
157 page = web.get("/web/title/movie/1329016").get_data(as_text=True)
158 assert "from HdHub" in page and "vidcore (flixlab) rejected" in page
159 assert "/web/source/allow" in page
160
161
162def test_a_held_copy_says_where_it_came_from(web, monkeypatch):
163 _hold_fetched("hdhub")
164 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
165 page = web.get("/web/title/movie/1329016").get_data(as_text=True)
166 assert "in library · HdHub" in page
167
168
169def test_a_running_download_says_where_from():
170 html = addon._web_state_html({"status": "running", "key": "k", "bytes": 5e8,
171 "source": "vidcore"})
172 assert "from vidcore (flixlab)" in html
173
174
175def test_replace_rejects_the_copys_source_and_refetches(web, monkeypatch):
176 dest = _hold_fetched("vidcore")
177 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
178 r = web.post("/web/replace", data={"kind": "movie", "tmdb": "1329016",
179 "height": "1080"})
180 assert r.status_code == 303
181 assert addon._rejected_sources(dest) == {"vidcore"}
182 (args, kwargs), = web.started
183 assert args == ("movie", "tmdb:1329016", 0, 0, 1080) and kwargs == {"force": True}
184 assert os.path.exists(dest), "the old copy stays until the new one is whole"
185
186
187def test_replace_refuses_a_copy_whose_source_is_unknown(web, monkeypatch):
188 dest = _hold_fetched(None)
189 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
190 r = web.post("/web/replace", data={"kind": "movie", "tmdb": "1329016"})
191 assert r.status_code == 409 and web.started == []
192 assert addon._rejected_sources(dest) == set()
193
194
195def test_replace_refuses_a_copy_this_addon_did_not_fetch(web, monkeypatch):
196 """The forced re-fetch overwrites the file -- same scope rule as Delete."""
197 dest = _dest()
198 os.makedirs(os.path.dirname(dest), exist_ok=True)
199 open(dest, "wb").write(b"x")
200 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
201 r = web.post("/web/replace", data={"kind": "movie", "tmdb": "1329016"})
202 assert r.status_code == 404 and web.started == []
203
204
205def test_replace_refuses_when_no_other_source_is_left(web, monkeypatch):
206 dest = _hold_fetched("vidcore")
207 addon._reject_source(dest, "hdhub")
208 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
209 r = web.post("/web/replace", data={"kind": "movie", "tmdb": "1329016"})
210 assert r.status_code == 409 and web.started == []
211
212
213def test_replace_is_post_only(web):
214 assert web.get("/web/replace").status_code == 405
215
216
217def test_reject_and_allow_routes(web):
218 form = {"kind": "movie", "tmdb": "1329016", "source": "vidcore"}
219 assert web.post("/web/source/reject", data=form).status_code == 303
220 assert addon._rejected_sources(_dest()) == {"vidcore"}
221 assert web.post("/web/source/allow", data=form).status_code == 303
222 assert addon._rejected_sources(_dest()) == set()
223
224
225def test_an_unknown_source_cannot_be_rejected(web):
226 r = web.post("/web/source/reject",
227 data={"kind": "movie", "tmdb": "1329016", "source": "../x"})
228 assert r.status_code == 400
229
230
231def test_the_watch_page_offers_a_replace_from_the_other_source(web, monkeypatch):
232 _hold_fetched("vidcore")
233 monkeypatch.setattr(addon, "_copy_is_complete", lambda *a, **k: True)
234 monkeypatch.setattr(addon, "_ffprobe_file", lambda p: None)
235 page = web.get("/web/watch/movie/1329016").get_data(as_text=True)
236 assert "Bad copy — replace from HdHub" in page and "/web/replace" in page
test_suite_isolation.py +4 -0
……@@ -30,3 +30,7 @@ def test_subtitle_sync_state_is_never_production():
3030 assert os.path.dirname(os.path.abspath(addon.SUBTITLE_SYNC_FILE)) != HERE
3131 assert os.path.dirname(os.path.abspath(addon.SUBS_ORIGINAL_DIR)) != HERE
3232 assert addon._subsync_available() is False
33
34
35def test_source_rejects_are_never_production():
36 assert os.path.dirname(os.path.abspath(addon.SOURCE_REJECTS_FILE)) != HERE