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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user