| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | | "version": "0.28.1", |
| 622 | "version": "0.29.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"], |
| … | … | @@ -1347,6 +1347,74 @@ def _record_attempt(key: str, info: dict, state: str, started: float) -> None: |
| 1347 | 1347 | "took": round(time.time() - started, 1), "dest": dest, |
| 1348 | 1348 | }) |
| 1349 | 1349 | |
| 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. |
| 1356 | SOURCE_REJECTS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), |
| 1357 | "source_rejects.json") |
| 1358 | _rejects_guard = threading.Lock() |
| 1359 | |
| 1360 | |
| 1361 | def _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 | |
| 1373 | def _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 | |
| 1383 | def _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 | |
| 1405 | def _reject_source(dest: str, sid: str) -> None: |
| 1406 | _change_rejects(dest, sid, True) |
| 1407 | |
| 1408 | |
| 1409 | def _allow_source(dest: str, sid: str) -> None: |
| 1410 | _change_rejects(dest, sid, False) |
| 1411 | |
| 1412 | |
| 1413 | def _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 | |
| 1350 | 1418 | # Job state was in-memory only, so a restart lost every intent: a copy that was |
| 1351 | 1419 | # mid-flight never resumed, and since a partial writes to `<dest>.part` while the |
| 1352 | 1420 | # 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, |
| 2544 | 2612 | # vidcore first: it is asked by TMDB id, so what it returns IS this title. |
| 2545 | 2613 | # HdHub is the fallback because its answer is matched by filename -- see |
| 2546 | 2614 | # 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" |
| 2548 | 2620 | started = time.time() |
| 2549 | 2621 | failure = None |
| 2550 | 2622 | _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) |
| 2552 | 2625 | # The resolve is a browser subprocess and is let run to its end; Cancel is |
| 2553 | 2626 | # honoured the moment it returns, before any byte is fetched. |
| 2554 | 2627 | if _cancel_requested(): |
| 2555 | 2628 | return "cancelled" |
| 2556 | | if url: |
| 2629 | if "vidcore" not in order: |
| 2630 | failure = "failed: vidcore rejected for this title" |
| 2631 | elif url: |
| 2557 | 2632 | variant = _cap_variant(url, height) |
| 2558 | 2633 | # The preview plays exactly what is being copied (labs#13). |
| 2559 | 2634 | 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, |
| 2576 | 2651 | if failure: |
| 2577 | 2652 | if _cancel_requested(): |
| 2578 | 2653 | return "cancelled" |
| 2654 | if "hdhub" not in order: |
| 2655 | return f"{failure}; hdhub: rejected for this title" |
| 2579 | 2656 | started = time.time() |
| 2580 | 2657 | _job_update(key, phase="downloading", source="hdhub", |
| 2581 | 2658 | downloading_since=started) |
| … | … | @@ -4372,13 +4449,15 @@ def _web_state(kind: str, tmdb: str, season: int = 0, |
| 4372 | 4449 | """ |
| 4373 | 4450 | ident = f"tmdb:{tmdb}" |
| 4374 | 4451 | 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} |
| 4376 | 4454 | states = [_mp4_jobs.get(_mp4_key(kind, ident, season, episode, h)) |
| 4377 | 4455 | for h in VIDCORE_HEIGHTS] |
| 4378 | 4456 | if "running" in states: |
| 4379 | 4457 | key = next(_mp4_key(kind, ident, season, episode, h) |
| 4380 | 4458 | for h, st in zip(VIDCORE_HEIGHTS, states) if st == "running") |
| 4381 | 4459 | return {"status": "running", "key": key, |
| 4460 | "source": (_job_info.get(key) or {}).get("source"), |
| 4382 | 4461 | "bytes": _ingest_progress(kind, ident, season, episode)} |
| 4383 | 4462 | failed = next((s for s in states if s and s.startswith("failed")), None) |
| 4384 | 4463 | if failed: |
| … | … | @@ -4388,15 +4467,33 @@ def _web_state(kind: str, tmdb: str, season: int = 0, |
| 4388 | 4467 | return {"status": "none"} |
| 4389 | 4468 | |
| 4390 | 4469 | |
| 4470 | def _source_name(sid: Optional[str]) -> str: |
| 4471 | src = get_source(sid or "") |
| 4472 | return src.name if src else (sid or "") |
| 4473 | |
| 4474 | |
| 4475 | def _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 | |
| 4391 | 4485 | def _web_state_html(state: dict, watch: Optional[str] = None) -> str: |
| 4392 | 4486 | s = state["status"] |
| 4393 | 4487 | 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>" |
| 4395 | 4490 | + (f" <a class=watch href='{watch}'>Watch</a>" if watch else "")) |
| 4396 | 4491 | if s == "running": |
| 4397 | 4492 | n = state.get("bytes") |
| 4398 | 4493 | 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>" |
| 4400 | 4497 | + _preview_link(state.get("key"))) |
| 4401 | 4498 | if s == "failed": |
| 4402 | 4499 | 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, |
| 4413 | 4510 | f"<input type=hidden name='episode' value='{episode}'>" |
| 4414 | 4511 | f"<input type=hidden name='scope' value='{scope}'>" |
| 4415 | 4512 | 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 | |
| 4517 | def _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 | |
| 4536 | def _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>") |
| 4417 | 4545 | |
| 4418 | 4546 | |
| 4419 | 4547 | def _web_low_space() -> str: |
| … | … | @@ -4908,6 +5036,7 @@ def _web_watch(kind: str, tmdb: str, season: int = 0, episode: int = 0): |
| 4908 | 5036 | f"<video controls preload=metadata playsinline src='{file_url}'" |
| 4909 | 5037 | " style='width:100%;max-height:70vh;background:#000'></video>" |
| 4910 | 5038 | "<h2>What this copy is</h2>" + _quality_html(path, meta) |
| 5039 | + _web_replace_form(kind, tmdb, season, episode, path) |
| 4911 | 5040 | + _web_delete_form(kind, tmdb, season, episode)) |
| 4912 | 5041 | return _web_page(f"{title} {sub} — watch", body) |
| 4913 | 5042 | |
| … | … | @@ -5102,6 +5231,109 @@ def web_delete(): |
| 5102 | 5231 | return redirect(back, code=303) |
| 5103 | 5232 | |
| 5104 | 5233 | |
| 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 | |
| 5242 | def _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 | |
| 5256 | def _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 | |
| 5260 | def _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"]) |
| 5281 | def 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"]) |
| 5300 | def 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 | |
| 5315 | def _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 | |
| 5105 | 5337 | @app.route("/web/watch/movie/<tmdb>") |
| 5106 | 5338 | def web_watch_movie(tmdb: str): |
| 5107 | 5339 | return _web_watch("movie", tmdb) |