"""Generic stats-provider abstraction. A service type that exposes a set of named numeric metrics (Jellyseerr request stats today; Sonarr/Radarr later) registers a :class:`StatsProvider`. The widget layer renders the provider's output as either a single-stat widget (a selector picks one metric) or a stats-overview grid. Keeping this behind a small interface means future stats services reuse the same widgets + tab without per-service widget kinds. Providers are synchronous (they do blocking HTTP) and are run in a thread by the widget source / router. A provider should cache/de-duplicate fetches so that several widgets + the tab don't each hit the upstream service. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Protocol, runtime_checkable @dataclass(frozen=True) class StatValue: """One named metric.""" key: str label: str value: int | float @dataclass class StatsResult: """Normalized output of a stats provider.""" stats: list[StatValue] recent: list[dict[str, Any]] = field(default_factory=list) # Optional human note (e.g. "not configured"); surfaced as an error when # there are no stats. detail: str | None = None @runtime_checkable class StatsProvider(Protocol): """Return the current stats for a service instance. ``service`` is a duck-typed record with ``id``, ``service_type``, ``config`` and ``secrets`` (see widgets.sources.ServiceRecord). """ def fetch_stats(self, service: Any) -> StatsResult: ... # Registry keyed by service_type. A service type with no provider simply has no # stat widgets available. STATS_PROVIDERS: dict[str, StatsProvider] = {} def register_stats_provider(service_type: str, provider: StatsProvider) -> None: STATS_PROVIDERS[service_type] = provider def get_stats_provider(service_type: str) -> StatsProvider | None: return STATS_PROVIDERS.get(service_type)