b8cb29e330
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.
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""REST API for dashboard widget instances.
|
|
|
|
Widgets are either service-bound (``service_id`` + ``widget_kind`` from the
|
|
service definition) or built-in (``service_id`` is null; ``widget_kind`` is one
|
|
of the service-less kinds exposed by ``GET /api/widgets/builtin``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
|
|
from media_library_viewer_api.dependencies import get_settings_store
|
|
from media_library_viewer_api.integrations.base import validate_config
|
|
from media_library_viewer_api.integrations.registry import get_service_definition
|
|
from media_library_viewer_api.models.widgets import (
|
|
BuiltinWidgetKindInfo,
|
|
WidgetDataResponse,
|
|
WidgetInstance,
|
|
WidgetInstanceInput,
|
|
)
|
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
|
from media_library_viewer_api.widgets.builtin import (
|
|
BUILTIN_WIDGET_KINDS,
|
|
is_builtin_kind,
|
|
validate_builtin_config,
|
|
)
|
|
from media_library_viewer_api.widgets.sources import (
|
|
build_service_record,
|
|
get_builtin_adapter,
|
|
get_service_adapter,
|
|
get_stats_adapter,
|
|
)
|
|
|
|
|
|
class WidgetReferenceCreate(BaseModel):
|
|
"""Payload for creating a widget reference (live-link)."""
|
|
|
|
dashboard_scope: str
|
|
widget_id: str
|
|
sort_order: int = 0
|
|
|
|
|
|
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
|
|
"""Validate widget_kind + config against the service definition or built-ins."""
|
|
if body.service_id:
|
|
service = store.get_service(body.service_id)
|
|
if not service:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"Service {body.service_id} not found",
|
|
)
|
|
definition = get_service_definition(service["service_type"])
|
|
if definition is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"Unknown service type: {service['service_type']}",
|
|
)
|
|
widget_kind = definition.widget_kind(body.widget_kind)
|
|
if widget_kind is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=(f"Service type '{service['service_type']}' does not provide widget kind '{body.widget_kind}'"),
|
|
)
|
|
if widget_kind.config_model is not None:
|
|
try:
|
|
validate_config(widget_kind.config_model, body.config)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"Invalid widget config: {exc}",
|
|
) from exc
|
|
else:
|
|
if not is_builtin_kind(body.widget_kind):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=(
|
|
f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
|
|
),
|
|
)
|
|
try:
|
|
validate_builtin_config(body.widget_kind, body.config)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"Invalid widget config: {exc}",
|
|
) from exc
|
|
|
|
|
|
@router.get("/builtin")
|
|
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
|
"""Return metadata for service-less built-in widget kinds."""
|
|
return [
|
|
BuiltinWidgetKindInfo(
|
|
kind=wk.kind,
|
|
name=wk.name,
|
|
description=wk.description,
|
|
config_schema=wk.config_schema,
|
|
default_config=wk.default_config,
|
|
refresh_interval_ms=wk.refresh_interval_ms,
|
|
)
|
|
for wk in BUILTIN_WIDGET_KINDS.values()
|
|
]
|
|
|
|
|
|
@router.get("/instances")
|
|
def list_instances(
|
|
service_id: str | None = None,
|
|
scope: str | None = None,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> list[dict[str, Any]]:
|
|
"""Return widget instances, optionally filtered.
|
|
|
|
- ``?service_id=X``: only widgets for service X.
|
|
- ``?scope=dashboard``: only widgets with NULL service_id.
|
|
- ``?scope=service``: only widgets with a non-null service_id.
|
|
- No params: all widgets (backward-compatible).
|
|
"""
|
|
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
|
|
|
|
|
|
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
|
def create_instance(
|
|
body: WidgetInstanceInput,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Create a new widget instance."""
|
|
_validate_widget_input(body, store)
|
|
widget = store.upsert_widget(body.model_dump())
|
|
return WidgetInstance(**widget).model_dump()
|
|
|
|
|
|
@router.put("/instances/{widget_id}")
|
|
def update_instance(
|
|
widget_id: str,
|
|
body: WidgetInstanceInput,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Update an existing widget instance."""
|
|
existing = store.get_widget(widget_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
|
if body.id is not None and body.id != widget_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="ID in path does not match ID in body",
|
|
)
|
|
_validate_widget_input(body, store)
|
|
widget = store.upsert_widget(body.model_dump(), widget_id)
|
|
return WidgetInstance(**widget).model_dump()
|
|
|
|
|
|
@router.delete("/instances/{widget_id}")
|
|
def delete_instance(
|
|
widget_id: str,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, str]:
|
|
"""Delete a widget instance."""
|
|
existing = store.get_widget(widget_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
|
store.delete_widget(widget_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.get("/instances/{widget_id}/data")
|
|
async def fetch_data(
|
|
widget_id: str,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Fetch widget data through the registered source adapter."""
|
|
widget = store.get_widget(widget_id)
|
|
if not widget:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
|
|
|
service_id = widget.get("service_id")
|
|
widget_kind = widget.get("widget_kind") or ""
|
|
|
|
service: Any = None
|
|
if service_id:
|
|
service_row = store.get_service(service_id)
|
|
if not service_row:
|
|
return WidgetDataResponse(
|
|
widget_id=widget_id,
|
|
error=f"Service {service_id} not found",
|
|
fetched_at=int(time.time()),
|
|
).model_dump()
|
|
if not service_row.get("enabled", True):
|
|
return WidgetDataResponse(
|
|
widget_id=widget_id,
|
|
error="Service is disabled",
|
|
fetched_at=int(time.time()),
|
|
).model_dump()
|
|
adapter = (
|
|
get_stats_adapter()
|
|
if widget_kind in ("stat", "stats_overview")
|
|
else get_service_adapter(service_row["service_type"])
|
|
)
|
|
if adapter is None:
|
|
return WidgetDataResponse(
|
|
widget_id=widget_id,
|
|
error=f"No adapter for service type {service_row['service_type']}",
|
|
fetched_at=int(time.time()),
|
|
).model_dump()
|
|
service = build_service_record(store, service_row)
|
|
else:
|
|
adapter = get_builtin_adapter(widget_kind)
|
|
if adapter is None:
|
|
return WidgetDataResponse(
|
|
widget_id=widget_id,
|
|
error=f"Unknown built-in widget kind: {widget_kind}",
|
|
fetched_at=int(time.time()),
|
|
).model_dump()
|
|
|
|
try:
|
|
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Widget data fetch failed",
|
|
) from exc
|
|
|
|
return WidgetDataResponse(
|
|
widget_id=widget_id,
|
|
data=data if "error" not in data else None,
|
|
error=data.get("error"),
|
|
fetched_at=int(time.time()),
|
|
).model_dump()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Widget references (live-link widgets across dashboards)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/references")
|
|
def list_references(
|
|
dashboard_scope: str,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> list[dict[str, Any]]:
|
|
"""List widget references for a dashboard scope."""
|
|
return store.list_widget_references(dashboard_scope)
|
|
|
|
|
|
@router.post("/references", status_code=status.HTTP_201_CREATED)
|
|
def create_reference(
|
|
body: WidgetReferenceCreate,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Create a widget reference (live-link) on a dashboard."""
|
|
try:
|
|
return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
|
|
|
|
@router.delete("/references/{reference_id}")
|
|
def delete_reference(
|
|
reference_id: str,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, str]:
|
|
"""Remove a widget reference from a dashboard."""
|
|
store.delete_widget_reference(reference_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.put("/references/{reference_id}")
|
|
def update_reference(
|
|
reference_id: str,
|
|
sort_order: int,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Update a widget reference's sort_order (per-dashboard reordering)."""
|
|
try:
|
|
return store.update_widget_reference(reference_id, sort_order)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/references/{reference_id}/detach")
|
|
def detach_reference(
|
|
reference_id: str,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> dict[str, Any]:
|
|
"""Clone the referenced widget into a standalone instance and remove the reference."""
|
|
try:
|
|
cloned = store.detach_widget_reference(reference_id, "")
|
|
return WidgetInstance(**cloned).model_dump()
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|