Files
manage/backend/src/media_library_viewer_api/widgets/stats_provider.py
T
Developer b8cb29e330 feat(jellyseer): stats backend — provider, stat widgets, router (slice 1/3)
Groundwork for Jellyseerr request stats in the Jellyfin service, behind a
small reusable abstraction so future stats services (Sonarr/Radarr) reuse it.

Backend:
- JellyseerrClient.request_count() -> /api/v1/request/count (normalized
  total/pending/approved/declined/processing/available) and recent_requests()
  -> /api/v1/request mapped to {name,type,status,media_status,created_at}
  with numeric status enums labelled.
- widgets/stats_provider.py: StatsProvider protocol + registry keyed by
  service_type (StatValue/StatsResult). A thin generic interface.
- widgets/jellyseerr_stats.py: JellyseerrStatsProvider registered for the
  Jellyfin service; reuses one authenticated client per service (lru_cache) and
  caches the StatsResult for ~10s under a lock, so multiple widgets + the tab
  collapse onto one Jellyseerr fetch (same lesson as the qBittorrent client).
  Accepts jellyseerr_api_key from secrets OR config during the upcoming
  config->secret migration.
- Jellyfin service gains two widget kinds: `stat` (a Literal selector over the
  six stats — the "extract one value into a widget" affordance) and
  `stats_overview` (all stats + recent list).
- widgets router routes widget_kind in {stat, stats_overview} to a generic
  StatsWidgetSource (dispatches to the service type's provider), independent of
  service type.
- new /api/jellyseerr/stats router endpoint for the Requests tab (resolves the
  Jellyfin service by id or first-enabled; shares the provider cache).

Tests: provider normalization, not-configured, TTL caching; stat selector +
overview + unknown-stat widget dispatch; 7 new tests. 400/400 backend pass;
ruff clean.
2026-07-12 13:10:09 +00:00

63 lines
1.9 KiB
Python

"""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)