| … | … | @@ -4,6 +4,7 @@ with a vsembed.ru/CloudStream Pro fallback (currently broken — Cloudflare Turn |
| 4 | 4 | The fmovies path runs node + Playwright via subprocess to handle the JS-driven extraction. |
| 5 | 5 | """ |
| 6 | 6 | |
| 7 | import html |
| 7 | 8 | import json |
| 8 | 9 | import logging |
| 9 | 10 | import os |
| … | … | @@ -595,7 +596,7 @@ TV_GENRES = [ |
| 595 | 596 | |
| 596 | 597 | MANIFEST = { |
| 597 | 598 | "id": "org.tabertron.vsembed", |
| 598 | | "version": "0.13.3", |
| 599 | "version": "0.14.0", |
| 599 | 600 | "name": "Tabertron VsEmbed", |
| 600 | 601 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 601 | 602 | "types": ["movie", "series"], |
| … | … | @@ -1464,6 +1465,23 @@ def _record_verified(path: str, seconds: float, expected: float) -> None: |
| 1464 | 1465 | log.warning("verified store save failed: %s", exc) |
| 1465 | 1466 | |
| 1466 | 1467 | |
| 1468 | def _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 | |
| 1467 | 1485 | def _cached_media_duration(path: str) -> Optional[float]: |
| 1468 | 1486 | """_media_duration, keyed on the file's identity rather than its name. |
| 1469 | 1487 | |
| … | … | @@ -1657,7 +1675,7 @@ def _remux_to_mp4(variant_url: str, out_path: str) -> None: |
| 1657 | 1675 | |
| 1658 | 1676 | |
| 1659 | 1677 | def _run_ingest(kind: str, ident: str, season: int, episode: int, |
| 1660 | | height: int) -> str: |
| 1678 | height: int, force: bool = False) -> str: |
| 1661 | 1679 | """Fetch one title into the library. Synchronous; returns the final state. |
| 1662 | 1680 | |
| 1663 | 1681 | 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, |
| 1681 | 1699 | return f"failed: cannot identify {ident}" |
| 1682 | 1700 | dest = _library_path(kind, meta["title"], meta["year"], meta["tmdb_id"], |
| 1683 | 1701 | 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): |
| 1685 | 1710 | log.info("ingest: library already holds %s", os.path.basename(dest)) |
| 1686 | 1711 | return "done" |
| 1687 | 1712 | url = _resolve_vidcore(kind, ident, season, episode) |
| … | … | @@ -1700,7 +1725,8 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int, |
| 1700 | 1725 | return "done" |
| 1701 | 1726 | |
| 1702 | 1727 | |
| 1703 | | def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int) -> str: |
| 1728 | def _start_mp4_job(kind: str, ident: str, season: int, episode: int, |
| 1729 | height: int, force: bool = False) -> str: |
| 1704 | 1730 | """Kick off an ingest in the background. Returns the job state. |
| 1705 | 1731 | |
| 1706 | 1732 | 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 |
| 1719 | 1745 | def run() -> None: |
| 1720 | 1746 | acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT) |
| 1721 | 1747 | try: |
| 1722 | | state = (_run_ingest(kind, ident, season, episode, height) |
| 1748 | state = (_run_ingest(kind, ident, season, episode, height, force) |
| 1723 | 1749 | if acquired else "failed: remux slots busy") |
| 1724 | 1750 | except Exception as exc: # never leave a job "running" |
| 1725 | 1751 | log.warning("ingest crashed for %s: %s", key, exc) |
| … | … | @@ -2265,11 +2291,163 @@ def meta(ctype: str, meta_id: str): |
| 2265 | 2291 | return jsonify({"meta": meta_obj}) |
| 2266 | 2292 | |
| 2267 | 2293 | |
| 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 | |
| 2310 | AUDIT_OK, AUDIT_SHORT, AUDIT_MISSING, AUDIT_UNKNOWN = ( |
| 2311 | "ok", "short", "missing", "unknown") |
| 2312 | |
| 2313 | |
| 2314 | def _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 = """ |
| 2363 | body{font:14px/1.5 system-ui,sans-serif;margin:1.5rem;color:#222} |
| 2364 | table{border-collapse:collapse;width:100%;max-width:60rem} |
| 2365 | th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #ddd} |
| 2366 | th{font-weight:600;border-bottom:2px solid #999} |
| 2367 | tr.short{background:#fdecea} |
| 2368 | tr.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} |
| 2372 | button{padding:.3rem .7rem;cursor:pointer} |
| 2373 | td.num{text-align:right;font-variant-numeric:tabular-nums} |
| 2374 | """ |
| 2375 | |
| 2376 | |
| 2377 | def _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") |
| 2413 | def library_audit(): |
| 2414 | return Response(_audit_html(_audit_rows()), mimetype="text/html") |
| 2415 | |
| 2416 | |
| 2417 | @app.route("/library/audit.json") |
| 2418 | def library_audit_json(): |
| 2419 | return jsonify(rows=_audit_rows()) |
| 2420 | |
| 2421 | |
| 2422 | @app.route("/library/reingest", methods=["POST"]) |
| 2423 | def 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 | |
| 2268 | 2445 | @app.route("/") |
| 2269 | 2446 | def index(): |
| 2270 | 2447 | return ( |
| 2271 | 2448 | "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>" |
| 2273 | 2451 | ) |
| 2274 | 2452 | |
| 2275 | 2453 | |