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.
This commit is contained in:
Developer
2026-07-12 13:10:09 +00:00
parent ba01ad7c0c
commit b8cb29e330
18 changed files with 525 additions and 29 deletions
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/widgets
## role
Provides widget data adapters and configuration definitions that fetch, normalize, and validate content from both built-in and external services for dashboard display.
Provides data fetching, normalization, and configuration logic for dashboard widgets across various integrated services and data sources.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -11,13 +11,15 @@ map: backend/src/media_library_viewer_api/.pi-map.md
## files
- __init__.py
- builtin.py
- jellyseerr_stats.py
- prometheus_range.py
- sources.py
- stats_provider.py
## links
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
## workflows
- change widgets behavior
read: __init__.py, builtin.py, prometheus_range.py
read: __init__.py, builtin.py, jellyseerr_stats.py
## dirty
-
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
"""Jellyseerr stats provider — request counts + recent requests.
Jellyseerr is an optional companion of the Jellyfin service (config lives on
the Jellyfin instance as ``jellyseerr_url`` / ``jellyseerr_api_key``). This
provider is registered for ``service_type == "jellyfin"`` and returns the
headline request stats (total / pending / approved / declined / processing /
available) plus a recent-requests list.
To avoid several widgets + the Requests tab each hitting Jellyseerr, one
authenticated client is reused per service (lru_cache) and the stats result is
cached for a short TTL with a lock — the same pattern the qBittorrent client
uses to keep a single-threaded upstream from being hammered.
"""
from __future__ import annotations
import logging
import threading
import time
from functools import lru_cache
from typing import Any
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.widgets.stats_provider import (
StatsResult,
StatValue,
register_stats_provider,
)
logger = logging.getLogger(__name__)
# Short-TTL cache: multiple widgets + the tab collapse onto one Jellyseerr fetch.
JS_STATS_CACHE_TTL = 10.0
# (stat key, display label) — order is the overview/grid order.
_JELLYSEERR_STATS: list[tuple[str, str]] = [
("total", "Total"),
("pending", "Pending"),
("approved", "Approved"),
("declined", "Declined"),
("processing", "Processing"),
("available", "Available"),
]
@lru_cache(maxsize=16)
def _jellyseer_client(cache_key: tuple[str, str, str]) -> JellyseerrClient:
"""Reuse one authenticated client per (service, url, api_key)."""
_service_id, base_url, api_key = cache_key
return JellyseerrClient(base_url, api_key)
class JellyseerrStatsProvider:
"""StatsProvider backed by Jellyseerr /api/v1/request/count + /api/v1/request."""
def __init__(self, ttl: float = JS_STATS_CACHE_TTL) -> None:
self._ttl = ttl
self._cache: dict[str, tuple[float, StatsResult]] = {}
self._lock = threading.Lock()
def fetch_stats(self, service: Any) -> StatsResult:
base_url = str(service.config.get("jellyseerr_url") or "")
# jellyseerr_api_key is migrating config -> secret; accept either during the transition.
api_key = str(
(service.secrets or {}).get("jellyseerr_api_key")
or service.config.get("jellyseerr_api_key")
or ""
)
if not base_url or not api_key:
return StatsResult(
stats=[],
detail="Jellyseerr is not configured for this Jellyfin instance "
"(set jellyseerr_url + jellyseerr_api_key on the Jellyfin service).",
)
now = time.time()
with self._lock:
hit = self._cache.get(service.id)
if hit and (now - hit[0]) < self._ttl:
return hit[1]
try:
client = _jellyseer_client((service.id, base_url, api_key))
counts = client.request_count()
recent = client.recent_requests(20)
except Exception as exc:
logger.warning("Jellyseerr stats fetch failed for %s: %s", service.id, exc)
# Serve stale if we have it, else surface the error.
if hit:
return hit[1]
return StatsResult(stats=[], detail=f"Jellyseerr fetch failed: {exc}")
result = StatsResult(
stats=[
StatValue(key=key, label=label, value=int(counts.get(key, 0)))
for key, label in _JELLYSEERR_STATS
],
recent=recent,
)
with self._lock:
self._cache[service.id] = (time.time(), result)
return result
register_stats_provider("jellyfin", JellyseerrStatsProvider())
@@ -29,12 +29,14 @@ from media_library_viewer_api.integrations.alertmanager import summarize_alerts
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets import jellyseerr_stats # noqa: F401 — registers the Jellyseerr stats provider
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
normalize_grafana_frames,
normalize_prometheus_matrix, # noqa: F401 — kept for future direct_url path (design decision 5)
step_for_window,
)
from media_library_viewer_api.widgets.stats_provider import get_stats_provider
logger = logging.getLogger(__name__)
@@ -493,3 +495,49 @@ def get_service_adapter(service_type: str) -> WidgetSource | None:
def get_builtin_adapter(kind: str) -> WidgetSource | None:
return BUILTIN_ADAPTERS.get(kind)
class StatsWidgetSource:
"""Generic source for ``stat`` and ``stats_overview`` widgets.
Dispatches to the service type's registered :class:`StatsProvider`, so any
stats-provider service gets these two widget kinds for free. The widgets
router routes widget_kind in {"stat", "stats_overview"} here regardless of
service type.
"""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
if service is None:
return {"error": "Stats widget is missing its service"}
provider = get_stats_provider(service.service_type)
if provider is None:
return {"error": f"No stats provider for service type '{service.service_type}'"}
timeout = int(service.config.get("timeout_seconds") or 30)
try:
result = await asyncio.wait_for(
asyncio.to_thread(provider.fetch_stats, service), timeout=timeout
)
except asyncio.TimeoutError:
return {"error": "Stats fetch timed out"}
except Exception as exc:
logger.exception("stats provider failed service=%s", service.id)
return {"error": f"Stats fetch failed: {exc}"}
if result.detail and not result.stats:
return {"error": result.detail}
if widget_kind == "stats_overview":
return {
"stats": [{"key": s.key, "label": s.label, "value": s.value} for s in result.stats],
"recent": result.recent,
}
stat_key = str(config.get("stat") or "")
match = next((s for s in result.stats if s.key == stat_key), None)
if match is None:
return {"error": f"Unknown stat '{stat_key}'"}
return {"key": match.key, "label": match.label, "value": match.value}
_STATS_ADAPTER = StatsWidgetSource()
def get_stats_adapter() -> WidgetSource | None:
return _STATS_ADAPTER
@@ -0,0 +1,62 @@
"""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)