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