Files
script.service.ultimate/lib/streaming_providers/base/channel_cache.py
T

418 lines
17 KiB
Python
Raw Normal View History

2026-10-27 11:47:04 +01:00
# streaming_providers/base/channel_cache.py
"""
2026-10-27 13:05:44 +01:00
In-memory channel-list cache with single-flight loading, stale-while-
revalidate and a per-provider circuit breaker.
2026-10-27 11:47:04 +01:00
What it gives the PVR client:
* a warm cache: GET /api/providers/<p>/channels is answered from memory
* single-flight: concurrent callers (warm-up, PVR, M3U generation) share ONE
upstream load per (provider, params); nobody logs in twice in parallel
* stale-while-revalidate: past the TTL the old list is served at once and
refreshed in a background thread; only a cold miss blocks
2026-10-27 13:05:44 +01:00
* a status per provider for /api/providers (see status())
Circuit breaker (never changes configuration, only controls RETRYING):
A load counts as failed when it raises OR returns no channels (many
providers log the error and return []). Consecutive failures back off:
2026-10-27 20:57:25 +01:00
kind delays (s) why
transient 60, 300, 900, 3600, 10800, 21600 timeouts, 5xx, ...
empty 900, 3600, 10800, 21600 swallowed error / no channels
rate_limit 3600, 10800, 43200 (>= Retry-After) HTTP 429 / RateLimitError
unauthorized 900, 3600, 21600, 86400 HTTP 401/403 (token may be stale)
auth 21600, 86400 wrong credentials (AuthError, ...)
geo 86400 GeoBlockError
Classification looks at the exception, its __cause__/__context__ chain, the
HTTP status found there or in the message ("401 Client Error", "(HTTP 406)")
and a few credential phrases, so wrapped and not yet migrated providers are
handled; providers that raise base.errors classes are classified exactly.
2026-10-27 13:05:44 +01:00
Both the lazy request path and the background refresher honour the delay,
so a failing login is never repeated by client retries or refresh ticks.
A list that was loaded successfully is NEVER replaced by a failed or empty
reload. invalidate() (credentials changed, reinitialize, enable/disable,
rediscover) resets the breaker of that provider.
2026-10-27 11:47:04 +01:00
Memory only, by design: channel objects are not serialised anywhere yet.
2026-10-27 13:05:44 +01:00
A load that was already running when invalidate() was called is delivered to
its waiters but never stored (generation counter); a list, failure or running
load that belongs to a replaced provider INSTANCE is never served (owner).
2026-10-27 11:47:04 +01:00
"""
import re
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Hashable, List, Optional, Tuple
from .utils.logger import logger
2026-10-27 13:05:44 +01:00
DEFAULT_TTL = 24 * 3600.0 # channel lists are stable: fresh for 24 h
2026-10-27 11:47:04 +01:00
DEFAULT_MAX_STALE = 7 * 24 * 3600.0 # serve stale (and refresh) up to 7 days; older = cold miss
2026-10-27 13:05:44 +01:00
EMPTY_TTL = 30.0 # an empty list is shown as stale at once (its retry is governed by the breaker)
BACKOFF: Dict[str, Tuple[float, ...]] = {
"transient": (60, 300, 900, 3600, 10800, 21600),
"empty": (900, 3600, 10800, 21600),
"rate_limit": (3600, 10800, 43200),
2026-10-27 20:57:25 +01:00
"unauthorized": (900, 3600, 21600, 86400),
2026-10-27 13:05:44 +01:00
"auth": (21600, 86400),
"geo": (86400,),
}
2026-10-27 11:47:04 +01:00
Key = Tuple[str, Hashable]
_URL_RE = re.compile(r"https?://\S+")
2026-10-27 20:57:25 +01:00
def safe_error(exc: BaseException) -> str:
"""Error text for logs and the API: no URLs (they may carry tokens), max 200 chars."""
2026-10-27 11:47:04 +01:00
return _URL_RE.sub("<url>", f"{type(exc).__name__}: {exc}")[:200]
2026-10-27 20:57:25 +01:00
_STATUS_RE = re.compile(
r"\b(\d{3}) (?:Client|Server) Error\b|\bHTTP(?: error)? (\d{3})\b|\(HTTP (\d{3})\)", re.I
)
_CREDENTIAL_RE = re.compile(
r"authentication failed|invalid credentials|invalid (?:user|password)|wrong password", re.I
)
def _chain(exc: BaseException) -> List[BaseException]:
chain: List[BaseException] = []
seen = set()
while exc is not None and id(exc) not in seen and len(chain) < 5:
seen.add(id(exc))
chain.append(exc)
exc = exc.__cause__ or exc.__context__
return chain
def _http_status(exc: BaseException) -> Optional[int]:
chain = _chain(exc)
for e in chain:
for obj in (getattr(e, "response", None), e):
code = getattr(obj, "status_code", None)
if isinstance(code, int):
return code
for e in chain:
match = _STATUS_RE.search(str(e))
if match:
return int(next(g for g in match.groups() if g))
return None
2026-10-27 13:05:44 +01:00
def classify_error(exc: BaseException) -> str:
2026-10-27 20:57:25 +01:00
"""transient | rate_limit | unauthorized | auth | geo (see module docstring)."""
names = {cls.__name__ for e in _chain(exc) for cls in type(e).__mro__}
status = _http_status(exc)
2026-10-27 13:05:44 +01:00
if "RateLimitError" in names or status == 429:
return "rate_limit"
if "GeoBlockError" in names:
return "geo"
2026-10-27 20:57:25 +01:00
message = " | ".join(str(e) for e in _chain(exc))
if (
any(("Auth" in n and "Unauthorized" not in n) or "Credential" in n or "Entitlement" in n for n in names)
or _CREDENTIAL_RE.search(message)
):
return "auth"
if status in (401, 403) or any("Unauthorized" in n for n in names):
return "unauthorized"
2026-10-27 13:05:44 +01:00
return "transient"
def _retry_after(exc: Optional[BaseException]) -> float:
2026-10-27 20:57:25 +01:00
for e in _chain(exc) if exc is not None else []:
headers = getattr(getattr(e, "response", None), "headers", None)
try:
value = float(headers.get("Retry-After")) if headers else 0.0
except (TypeError, ValueError, AttributeError):
continue
if 0 < value <= 86400:
return value
return 0.0
2026-10-27 13:05:44 +01:00
2026-10-27 11:47:04 +01:00
@dataclass
class _Entry:
channels: List[Any]
fetched_at: float
ttl: float
retry_after: float = 0.0
owner: Any = None # the provider instance this list came from
2026-10-27 13:05:44 +01:00
@dataclass
class _Health:
failures: int
next_retry: float
kind: str
2026-10-27 11:47:04 +01:00
class _Flight:
__slots__ = ("done", "result", "error", "gen", "owner")
def __init__(self, gen: int, owner: Any = None):
self.owner = owner
self.done = threading.Event()
self.result: Optional[List[Any]] = None
self.error: Optional[BaseException] = None
self.gen = gen
class ChannelCache:
def __init__(
self,
ttl: float = DEFAULT_TTL,
max_stale: float = DEFAULT_MAX_STALE,
2026-10-27 13:05:44 +01:00
empty_ttl: float = EMPTY_TTL,
2026-10-27 11:47:04 +01:00
clock: Callable[[], float] = time.monotonic,
):
self._ttl = ttl
self._max_stale = max_stale
self._empty_ttl = empty_ttl
self._clock = clock
self._lock = threading.Lock()
self._entries: Dict[Key, _Entry] = {}
self._flights: Dict[Key, _Flight] = {}
self._generation: Dict[str, int] = {}
self._last_error: Dict[str, str] = {}
2026-10-27 13:05:44 +01:00
self._health: Dict[str, _Health] = {}
2026-10-27 11:47:04 +01:00
self._failures: Dict[Key, Tuple[BaseException, float, Any]] = {}
# ------------------------------------------------------------------
def get(
self,
provider: str,
loader: Callable[[], List[Any]],
*,
params: Hashable = (),
ttl: Optional[float] = None,
owner: Any = None,
) -> List[Any]:
"""
Return the channel list, loading it through `loader` when needed.
`owner` is the provider instance. A list (or failure, or running load)
2026-10-27 13:05:44 +01:00
that belongs to a DIFFERENT instance is never served.
2026-10-27 11:47:04 +01:00
"""
key: Key = (provider, params)
ttl = self._ttl if ttl is None else ttl
now = self._clock()
with self._lock:
entry = self._entries.get(key)
if entry is not None and owner is not None and entry.owner is not owner:
del self._entries[key]
entry = None
if entry is not None:
age = now - entry.fetched_at
if age < entry.ttl:
return list(entry.channels)
if age < self._max_stale:
stale = list(entry.channels)
if now >= entry.retry_after:
flight, created = self._flight_locked(key, owner)
if created:
self._spawn_refresh(key, provider, loader, flight, ttl)
return stale
if key not in self._flights:
failure = self._failures.get(key)
if failure is not None:
2026-10-27 13:05:44 +01:00
if (owner is not None and failure[2] is not owner) or now >= failure[1]:
2026-10-27 11:47:04 +01:00
del self._failures[key]
2026-10-27 13:05:44 +01:00
else:
2026-10-27 11:47:04 +01:00
# Same answer as the failed load, without asking the upstream again.
raise failure[0].with_traceback(None)
flight, created = self._flight_locked(key, owner)
if created:
self._run(key, provider, loader, flight, ttl)
else:
flight.done.wait()
if flight.error is not None:
raise flight.error
return list(flight.result or [])
def refresh(
self,
provider: str,
loader: Callable[[], List[Any]],
*,
params: Hashable = (),
ttl: Optional[float] = None,
owner: Any = None,
) -> bool:
"""
Reload now, in the calling thread, even if the entry is still fresh.
Single-flight like get(): returns False (and does nothing) when a load
for this key is already running. On failure the old list is kept and
the exception propagates (used by the background refresher).
"""
key: Key = (provider, params)
with self._lock:
flight, created = self._flight_locked(key, owner)
if not created:
return False
self._run(key, provider, loader, flight, self._ttl if ttl is None else ttl)
return True
def due(self, fraction: float) -> List[Key]:
"""Keys whose age reached `fraction` of their TTL and are not in backoff."""
now = self._clock()
with self._lock:
return [
key
for key, e in self._entries.items()
if now - e.fetched_at >= e.ttl * fraction and now >= e.retry_after
]
2026-10-27 13:05:44 +01:00
def can_retry(self, provider: str) -> bool:
"""False while the provider's circuit breaker is in a backoff window."""
with self._lock:
health = self._health.get(provider)
return health is None or self._clock() >= health.next_retry
2026-10-27 11:47:04 +01:00
def invalidate(self, provider: Optional[str] = None) -> None:
2026-10-27 13:05:44 +01:00
"""Drop cached lists (one provider, or all), reset the breaker, discard loads in flight."""
2026-10-27 11:47:04 +01:00
with self._lock:
if provider is None:
names = (
set(self._generation)
| {k[0] for k in self._entries}
| {k[0] for k in self._flights}
)
self._entries.clear()
self._last_error.clear()
self._failures.clear()
2026-10-27 13:05:44 +01:00
self._health.clear()
2026-10-27 11:47:04 +01:00
else:
names = {provider}
for key in [k for k in self._entries if k[0] == provider]:
del self._entries[key]
self._last_error.pop(provider, None)
2026-10-27 13:05:44 +01:00
self._health.pop(provider, None)
2026-10-27 11:47:04 +01:00
for key in [k for k in self._failures if k[0] == provider]:
del self._failures[key]
for name in names:
self._generation[name] = self._generation.get(name, 0) + 1
def status(self, provider: str) -> Dict[str, Any]:
2026-10-27 13:05:44 +01:00
"""
Snapshot for the API.
state: idle | warming | ready | error
ready: channel_count, age_seconds, stale (True = list is old; THIS is
the signal for "old", not `state`), refreshing
any state: error (sanitised text), error_kind, failures, retry_in
(seconds until the next automatic attempt)
"""
2026-10-27 11:47:04 +01:00
now = self._clock()
with self._lock:
entries = [e for k, e in self._entries.items() if k[0] == provider]
loading = any(k[0] == provider for k in self._flights)
error = self._last_error.get(provider)
2026-10-27 13:05:44 +01:00
health = self._health.get(provider)
extra: Dict[str, Any] = {"error": error}
if health is not None:
extra.update(
error_kind=health.kind,
failures=health.failures,
retry_in=max(0, int(health.next_retry - now)),
)
2026-10-27 11:47:04 +01:00
if entries:
newest = max(entries, key=lambda e: e.fetched_at)
age = now - newest.fetched_at
return {
"state": "ready",
"channel_count": len(newest.channels),
"age_seconds": int(age),
"stale": age >= newest.ttl,
"refreshing": loading,
2026-10-27 13:05:44 +01:00
**extra,
2026-10-27 11:47:04 +01:00
}
if loading:
2026-10-27 13:05:44 +01:00
return {"state": "warming", **extra}
2026-10-27 11:47:04 +01:00
if error:
2026-10-27 13:05:44 +01:00
return {"state": "error", **extra}
2026-10-27 11:47:04 +01:00
return {"state": "idle"}
# ------------------------------------------------------------------
def _flight_locked(self, key: Key, owner: Any = None) -> Tuple[_Flight, bool]:
flight = self._flights.get(key)
if flight is not None:
if owner is None or flight.owner is owner:
return flight, False
# Running load belongs to a replaced instance: discard its result, start anew.
self._generation[key[0]] = self._generation.get(key[0], 0) + 1
flight = _Flight(self._generation.get(key[0], 0), owner)
self._flights[key] = flight
return flight, True
2026-10-27 13:05:44 +01:00
def _record_failure_locked(self, provider: str, kind: str, exc: Optional[BaseException]) -> _Health:
previous = self._health.get(provider)
count = (previous.failures if previous else 0) + 1
table = BACKOFF[kind]
delay = table[min(count, len(table)) - 1]
if kind == "rate_limit":
delay = max(delay, _retry_after(exc))
health = _Health(count, self._clock() + delay, kind)
self._health[provider] = health
logger.info(
f"ChannelCache: '{provider}' {kind} failure #{count}, next automatic attempt in {int(delay)}s"
)
return health
2026-10-27 11:47:04 +01:00
def _spawn_refresh(self, key, provider, loader, flight, ttl) -> None:
def work():
try:
self._run(key, provider, loader, flight, ttl)
2026-10-27 13:05:44 +01:00
except Exception as exc: # already recorded in the breaker
2026-10-27 20:57:25 +01:00
logger.warning(f"ChannelCache: background refresh of '{provider}' failed: {safe_error(exc)}")
2026-10-27 11:47:04 +01:00
threading.Thread(target=work, name=f"channel-refresh-{provider}", daemon=True).start()
def _run(self, key: Key, provider: str, loader, flight: _Flight, ttl: float) -> None:
try:
channels = list(loader())
except Exception as exc:
flight.error = exc
with self._lock:
if self._generation.get(provider, 0) == flight.gen:
2026-10-27 13:05:44 +01:00
health = self._record_failure_locked(provider, classify_error(exc), exc)
2026-10-27 20:57:25 +01:00
self._last_error[provider] = safe_error(exc)
2026-10-27 13:05:44 +01:00
self._failures[key] = (exc, health.next_retry, flight.owner)
2026-10-27 11:47:04 +01:00
entry = self._entries.get(key)
2026-10-27 13:05:44 +01:00
if entry is not None: # keep serving the old list, retry later
entry.retry_after = health.next_retry
2026-10-27 11:47:04 +01:00
raise
else:
flight.result = channels
with self._lock:
if self._generation.get(provider, 0) == flight.gen:
2026-10-27 13:05:44 +01:00
now = self._clock()
if channels:
self._entries[key] = _Entry(channels, now, ttl, owner=flight.owner)
self._health.pop(provider, None)
self._last_error.pop(provider, None)
self._failures.pop(key, None)
else:
health = self._record_failure_locked(provider, "empty", None)
self._last_error[provider] = "no channels returned"
old = self._entries.get(key)
if old is not None and old.channels and (flight.owner is None or old.owner is flight.owner):
old.retry_after = health.next_retry # never replace a good list by an empty one
else:
self._entries[key] = _Entry(
[], now, min(ttl, self._empty_ttl),
retry_after=health.next_retry, owner=flight.owner,
)
2026-10-27 11:47:04 +01:00
finally:
with self._lock:
if self._flights.get(key) is flight:
del self._flights[key]
if flight.result is None and flight.error is None:
flight.error = RuntimeError("channel load aborted")
flight.done.set()