"""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 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, ) 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_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()