| … | … | @@ -595,7 +595,7 @@ TV_GENRES = [ |
| 595 | 595 | |
| 596 | 596 | MANIFEST = { |
| 597 | 597 | "id": "org.tabertron.vsembed", |
| 598 | | "version": "0.13.0", |
| 598 | "version": "0.13.1", |
| 599 | 599 | "name": "Tabertron VsEmbed", |
| 600 | 600 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 601 | 601 | "types": ["movie", "series"], |
| … | … | @@ -872,6 +872,25 @@ def _cap_variant(master_url: str, max_height: int = VIDCORE_MAX_HEIGHT) -> str: |
| 872 | 872 | VIDCORE_PREFETCH_MAX = 1 |
| 873 | 873 | _prefetch_sem = threading.Semaphore(VIDCORE_PREFETCH_MAX) |
| 874 | 874 | |
| 875 | # How long a PLAY request may queue behind a resolve already in flight. |
| 876 | # |
| 877 | # The prefetch above shares a per-title lock with the play path, so until this |
| 878 | # existed, pressing play meant waiting out whatever the prefetch was doing -- |
| 879 | # with no bound short of VIDCORE_TIMEOUT. Measured 2026-08-12 on Supergirl |
| 880 | # (tmdb:1081003): /stream started a prefetch that ran COLD (launch=28866ms after |
| 881 | # 25h idle, against 595-760ms on every warm resolve) and took 57.7s; the play |
| 882 | # request arrived 4s later, queued for all of it, then paid its own 11.4s resolve |
| 883 | # on top and answered at 68s. Kodi allows an HTTP open 20s -- CCurlFile::Stat and |
| 884 | # CurlFile::Open each timed out at exactly that -- so it had given up 2.2s before |
| 885 | # the answer arrived. The prefetch, whose entire purpose is to take resolving off |
| 886 | # the play path, had put it back on and made that play slower than no prefetch at |
| 887 | # all (11.4s, which would have worked). |
| 888 | # |
| 889 | # 8s leaves room to inherit a resolve that is nearly done, while leaving over half |
| 890 | # of Kodi's budget for the redirect and the CDN's own first bytes. Overrunning it |
| 891 | # is not a slower play, it is a failed one. |
| 892 | VIDCORE_PLAY_DEADLINE = 8 |
| 893 | |
| 875 | 894 | |
| 876 | 895 | def _prefetch_vidcore(kind: str, ident: str, season: int = 0, |
| 877 | 896 | episode: int = 0) -> Optional[threading.Thread]: |
| … | … | @@ -897,8 +916,14 @@ def _prefetch_vidcore(kind: str, ident: str, season: int = 0, |
| 897 | 916 | return t |
| 898 | 917 | |
| 899 | 918 | |
| 900 | | def _resolve_vidcore(kind: str, ident: str, season: int = 0, episode: int = 0) -> Optional[str]: |
| 901 | | """`ident` is `tt<n>` (cinemeta path) or `tmdb:<n>` (fxl:/fmv: catalog paths).""" |
| 919 | def _resolve_vidcore(kind: str, ident: str, season: int = 0, episode: int = 0, |
| 920 | wait: Optional[float] = None) -> Optional[str]: |
| 921 | """`ident` is `tt<n>` (cinemeta path) or `tmdb:<n>` (fxl:/fmv: catalog paths). |
| 922 | |
| 923 | `wait` bounds how long to queue behind a resolve someone else already |
| 924 | started. None means wait as long as it takes, which is right for anything |
| 925 | running in the background and wrong for anything a player is waiting on. |
| 926 | """ |
| 902 | 927 | key = f"{kind}:{ident}:{season}:{episode}" |
| 903 | 928 | now = time.time() |
| 904 | 929 | hit = _vidcore_cache.get(key) |
| … | … | @@ -912,11 +937,20 @@ def _resolve_vidcore(kind: str, ident: str, season: int = 0, episode: int = 0) - |
| 912 | 937 | # pure waste: hold a per-title lock so the followers wait and take the cache. |
| 913 | 938 | with _vidcore_locks_guard: |
| 914 | 939 | lock = _vidcore_locks.setdefault(key, threading.Lock()) |
| 915 | | with lock: |
| 940 | if not (lock.acquire() if wait is None else lock.acquire(timeout=wait)): |
| 941 | # Someone else is still working. Take a cached answer if one appeared |
| 942 | # while we waited, otherwise give up: their resolve carries on and fills |
| 943 | # the cache, so the cost of stopping here is one click, not the title. |
| 944 | log.info("vidcore: %s in flight past %ss — not waiting", key, wait) |
| 945 | hit = _vidcore_cache.get(key) |
| 946 | return hit[1] if hit and hit[0] > time.time() else None |
| 947 | try: |
| 916 | 948 | hit = _vidcore_cache.get(key) # a waiter may have filled it |
| 917 | 949 | if hit and hit[0] > time.time(): |
| 918 | 950 | return hit[1] |
| 919 | 951 | return _resolve_vidcore_locked(key, kind, ident, season, episode) |
| 952 | finally: |
| 953 | lock.release() |
| 920 | 954 | |
| 921 | 955 | |
| 922 | 956 | def _master_playlist(master_url: str, |
| … | … | @@ -1109,7 +1143,7 @@ def vidcore_movie(ident: str): |
| 1109 | 1143 | local = _local_redirect("movie", ident, 0, 0, height) |
| 1110 | 1144 | if local: |
| 1111 | 1145 | return local |
| 1112 | | url = _resolve_vidcore("movie", ident) |
| 1146 | url = _resolve_vidcore("movie", ident, wait=VIDCORE_PLAY_DEADLINE) |
| 1113 | 1147 | if not url: |
| 1114 | 1148 | abort(404) |
| 1115 | 1149 | # Playing is the honest "I chose this" signal, so keep a copy of what is |
| … | … | @@ -1129,7 +1163,8 @@ def vidcore_series(ident: str): |
| 1129 | 1163 | local = _local_redirect("series", m.group(1), season, episode, height) |
| 1130 | 1164 | if local: |
| 1131 | 1165 | return local |
| 1132 | | url = _resolve_vidcore("series", m.group(1), season, episode) |
| 1166 | url = _resolve_vidcore("series", m.group(1), season, episode, |
| 1167 | wait=VIDCORE_PLAY_DEADLINE) |
| 1133 | 1168 | if not url: |
| 1134 | 1169 | abort(404) |
| 1135 | 1170 | _ensure_ingest("series", m.group(1), season, episode, height=height) |
| … | … | @@ -1641,7 +1676,8 @@ def _serve_mp4(kind: str, ident: str, season: int, episode: int): |
| 1641 | 1676 | # surface as a playback failure while the copy silently proceeded. The same |
| 1642 | 1677 | # URL serves the local file once it lands, so the row upgrades itself. |
| 1643 | 1678 | _ensure_ingest(kind, ident, season, episode, height) |
| 1644 | | url = _resolve_vidcore(kind, ident, season, episode) |
| 1679 | url = _resolve_vidcore(kind, ident, season, episode, |
| 1680 | wait=VIDCORE_PLAY_DEADLINE) |
| 1645 | 1681 | if not url: |
| 1646 | 1682 | abort(404) |
| 1647 | 1683 | return _serve_vidcore(url) |