from __future__ import annotations from dataclasses import dataclass from typing import Any import httpx from .config import HockeySettingsStore class Stat2TVNotFoundError(RuntimeError): def __init__(self, url: str, status_code: int = 404) -> None: self.url = url self.status_code = status_code super().__init__(f"Stat2TV resource not found: HTTP {status_code}") @dataclass(slots=True) class Stat2TVResponse: content: bytes status_code: int content_type: str auth_mode: str url: str class Stat2TVClient: def __init__(self, settings: HockeySettingsStore) -> None: self.settings_store = settings self._working_auth_mode: str | None = None self._clients: dict[bool, httpx.AsyncClient] = {} self._auth_signature: tuple[str, str, str] | None = None def _client(self, verify_ssl: bool) -> httpx.AsyncClient: # A bounded pool is shared by independent matches. Credentials remain # request-local; changing settings never leaks one request's auth. client = self._clients.get(verify_ssl) if client is None or client.is_closed: client = httpx.AsyncClient( verify=verify_ssl, follow_redirects=True, limits=httpx.Limits(max_connections=32, max_keepalive_connections=16), headers={"Accept": "application/xml,text/xml,*/*", "User-Agent": "HockeyControlPanel/20.2"}, ) self._clients[verify_ssl] = client return client async def aclose(self) -> None: clients, self._clients = list(self._clients.values()), {} for client in clients: await client.aclose() async def fetch_xml(self, endpoint: str) -> Stat2TVResponse: settings = self.settings_store.public() username, password = self.settings_store.credentials() if not username or not password: raise RuntimeError( "Логин и пароль Stat2TV не настроены. " "Откройте настройки хоккейного модуля." ) url = settings["base_url"].rstrip("/") + "/" + endpoint.lstrip("/") signature = (settings["base_url"], username, password) if self._auth_signature != signature: self._working_auth_mode = None self._auth_signature = signature requested_mode = settings["auth_mode"] if requested_mode in {"basic", "digest"}: modes = [requested_mode] elif self._working_auth_mode in {"basic", "digest"}: other = "digest" if self._working_auth_mode == "basic" else "basic" modes = [self._working_auth_mode, other] else: modes = ["basic", "digest"] last_error: Exception | None = None for mode in modes: auth: Any = ( httpx.BasicAuth(username, password) if mode == "basic" else httpx.DigestAuth(username, password) ) try: client = self._client(bool(settings["verify_ssl"])) response = await client.get(url, auth=auth, timeout=float(settings["request_timeout_seconds"])) if response.status_code in {401, 403} and requested_mode == "auto": last_error = RuntimeError( f"Авторизация {mode} отклонена: HTTP {response.status_code}" ) continue if response.status_code in {404, 410}: raise Stat2TVNotFoundError(str(response.url), response.status_code) response.raise_for_status() self._working_auth_mode = mode return Stat2TVResponse( content=response.content, status_code=response.status_code, content_type=response.headers.get("content-type", ""), auth_mode=mode, url=str(response.url), ) except Stat2TVNotFoundError: raise except Exception as error: last_error = error if requested_mode != "auto": break raise RuntimeError(f"Stat2TV недоступен: {last_error}")