"""upstream.py — the only module that talks to anidb.app.
anidb.app sits behind Cloudflare: plain curl gets 403. All requests use
curl_cffi Chrome TLS impersonation (verified to pass the challenge).
Parsers are regex against live markup — keep them here so the layout
breaks in one place, not across the app.
"""
import re
import sys
import threading
import time
from curl_cffi import requests
BASE = "https://anidb.app"
H = dict(impersonate="chrome", timeout=20)
# ponytail: thread-local sessions (uvicorn threadpool); curl_cffi Sessions
# are not thread-safe, but per-thread keep-alive still reuses TCP/TLS across
# the many requests a worker handles. Saves a handshake on every call.
_tls = threading.local()
def _session():
if not hasattr(_tls, "s"):
_tls.s = requests.Session()
return _tls.s
# ponytail: in-memory TTL cache for upstream data that never changes
# (details/episodes/langs/playlist URLs). No lock: worst case a duplicate
# fetch. A dead/rotated playlist URL is recovered by the one-shot
# clear_cache()+retry in api_stream.
_CACHE = {} # key -> (expires, value)
def _cached(key, ttl, fn, *a, **kw):
v = _CACHE.get(key)
if v and v[0] > time.time():
return v[1]
val = fn(*a, **kw)
_CACHE[key] = (time.time() + ttl, val)
if len(_CACHE) > 256:
_CACHE.clear()
return val
def clear_cache():
_CACHE.clear()
_ANIME_LINK = re.compile(r'anime/([a-z0-9-]+)-(\d+)"[^>]*title="([^"]*)"')
def _get(url, **kw):
# ponytail: one retry (300ms) on transient upstream errors (Cloudflare
# 403/503, 429, 5xx); 404 and other 4xx are final. Failing fast matters:
# hls.js's fragLoadingTimeOut (20s) races curl's timeout (20s), so a
# quick 502 lets hls.js's own frag retries (6x, backoff) do the waiting
for attempt in range(2):
r = _session().get(url, **{**H, **kw}) # kw overrides H (e.g. timeout)
if not (r.status_code in (403, 429, 503) or r.status_code >= 500
or "Just a moment" in r.text[:500]):
r.raise_for_status()
return r
if attempt:
raise RuntimeError(f"upstream {r.status_code}: {url}")
time.sleep(0.3)
def _search(q):
"""Step 1: browse page -> [{id, slug_id, name, poster}]"""
html = _get(f"{BASE}/browse", params={"q": q}).text
out, seen = [], set()
for m in re.finditer(
r']*title="([^"]*)"[^>]*>(.*?)',
html, re.S):
slug_id, name, block = m.group(1), m.group(2), m.group(3)
if slug_id in seen:
continue
seen.add(slug_id)
img = re.search(r' {name, synopsis, mal_id, poster, seasons}"""
p = _get(f"{BASE}/anime/{slug_id}").text
mal = re.search(r'myanimelist\.net/anime/(\d+)', p)
syn = re.search(r'>Synopsis.*?
]*>(.*?)
', p, re.S) h1 = re.search(r'