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
@@ -15,6 +15,23 @@ from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT,
logger = logging.getLogger(__name__)
# Jellyseerr numeric status enums (see Overseerr/Jellyseerr source).
_REQUEST_STATUS: dict[int, str] = {1: "pending", 2: "approved", 3: "declined"}
_MEDIA_STATUS: dict[int, str] = {
1: "unknown",
2: "pending",
3: "processing",
4: "partially_available",
5: "available",
}
def _label(value: Any, table: dict[int, str]) -> str:
try:
return table.get(int(value), str(value))
except (TypeError, ValueError):
return str(value) if value is not None else ""
class JellyseerrClient:
"""Small wrapper around the Jellyseerr REST API."""
@@ -136,3 +153,45 @@ class JellyseerrClient:
logger.info("Jellyseerr returned %s users", len(results))
return results
def request_count(self) -> dict[str, int]:
"""Return normalized request counts from /api/v1/request/count.
Jellyseerr reports pending/approved/declined/processing/available/total.
Missing keys default to 0 so callers can rely on a stable shape.
"""
payload = self.get("/request/count")
if not isinstance(payload, dict):
payload = {}
keys = ("total", "pending", "approved", "declined", "processing", "available")
counts = {k: int(payload.get(k) or 0) for k in keys}
logger.info(
"Jellyseerr request counts total=%s pending=%s processing=%s",
counts["total"],
counts["pending"],
counts["processing"],
)
return counts
def recent_requests(self, take: int = 20) -> list[dict[str, Any]]:
"""Return the most recently modified requests, lightly mapped."""
take = max(1, min(int(take), 100))
payload = self.get("/request", sort="modified", skip=0, take=take)
if not isinstance(payload, dict):
return []
results = payload.get("results") or []
items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
mapped: list[dict[str, Any]] = []
for r in items:
media = r.get("media") or {}
mapped.append(
{
"id": r.get("id"),
"type": r.get("type"),
"name": r.get("title") or media.get("title") or media.get("name") or "",
"status": _label(r.get("status"), _REQUEST_STATUS),
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
"created_at": r.get("createdAt"),
}
)
return mapped