feat(widgets): add backend source adapters and per-widget data endpoint

PR 2 of 4 for configurable dashboard widgets.

- Add grafana_url and prometheus_url settings (config.py + compose/env).
- Create WidgetSource protocol and adapters for jellyfin, backups, grafana,
  prometheus, ssh_task, and static sources.
- Add GET /api/widgets/instances/{id}/data endpoint.
- Extract shared dashboard helpers into domain/dashboard.py so widgets and
  the dashboard router reuse the same logic.
- Add adapter and data-endpoint tests.
- Update apply-progress.md.

Verification: ruff clean; backend pytest 200 passed; frontend lint/build green.
This commit is contained in:
Developer
2026-06-21 10:09:45 +00:00
parent 1a52dfb087
commit 1cd8e926de
10 changed files with 607 additions and 85 deletions
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
get_settings_store,
get_user_id,
)
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.models.backups import BackupDashboardSummary
from media_library_viewer_api.services.settings_store import SettingsStore
@@ -85,50 +88,6 @@ def delete_shortcut(
return {"status": "deleted"}
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize Jellyfin sessions into dashboard activity rows."""
results: list[dict[str, Any]] = []
for session in sessions:
item = session.get("NowPlayingItem") or {}
play_state = session.get("PlayState") or {}
transcoding = session.get("TranscodingInfo") or {}
has_item = bool(item)
series = item.get("SeriesName") or ""
title = (
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
)
if not has_item:
state_label = "idle"
else:
state_label = "paused" if play_state.get("IsPaused") else "playing"
is_transcoding = bool(transcoding)
transcode_type: list[str] = []
if is_transcoding:
if transcoding.get("IsVideoDirect") is False:
transcode_type.append("video")
if transcoding.get("IsAudioDirect") is False:
transcode_type.append("audio")
if not transcode_type:
transcode_type.append("active")
results.append(
{
"user": session.get("UserName") or "Unknown",
"title": title,
"type": item.get("Type", "") if has_item else "",
"state": state_label,
"transcoding": "yes" if is_transcoding else "no",
"transcoding_type": ", ".join(transcode_type),
"device": session.get("DeviceName") or session.get("Client") or "",
"session_id": session.get("Id") or "",
}
)
return results
@router.get("/activity")
def get_activity(
client: JellyfinClient = Depends(get_jellyfin_client),
@@ -154,38 +113,4 @@ def get_now_playing(
def get_backup_dashboard(
store: SettingsStore = Depends(get_settings_store),
) -> BackupDashboardSummary:
jobs = store.list_backup_jobs()
total_jobs = len(jobs)
# Calculate 24h success rate
cutoff = int(time.time()) - (24 * 60 * 60)
recent_runs = []
for job in jobs:
runs = store.list_backup_runs(job_id=job["id"], limit=1)
if runs and runs[0]["started_at"] >= cutoff:
recent_runs.append(runs[0])
successful = sum(1 for r in recent_runs if r["status"] == "success")
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
# Active alerts
alerts = store.list_backup_alerts(acknowledged=False)
active_alerts = len(alerts)
# Last failed
failed_runs = []
for job in jobs:
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
if runs:
failed_runs.append(runs[0])
last_failed_at = None
if failed_runs:
last_failed_at = max(r["started_at"] for r in failed_runs)
return BackupDashboardSummary(
total_jobs=total_jobs,
success_rate_24h=round(success_rate, 1),
active_alerts=active_alerts,
last_failed_at=last_failed_at,
)
return build_backup_dashboard_summary(store)
@@ -1,20 +1,30 @@
"""REST API for dashboard widget instances and registry metadata."""
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.models.widgets import WidgetInstance, WidgetInstanceInput, WidgetTypeInfo
from media_library_viewer_api.models.widgets import (
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
)
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.registry import (
get_widget_info,
list_source_types,
list_widget_types,
validate_config,
)
from media_library_viewer_api.widgets.sources import get_source_adapter
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__)
def _registry_for_type(widget_type: str) -> dict[str, Any]:
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
@@ -56,7 +66,7 @@ def list_sources() -> list[str]:
@router.get("/types")
def list_types() -> list[WidgetTypeInfo]:
def list_types() -> list[dict[str, Any]]:
"""Return metadata for all registered widget types."""
return [info.model_dump() for info in list_widget_types()]
@@ -111,3 +121,53 @@ def delete_instance(
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")
widget_type = widget["widget_type"]
info = get_widget_info(widget_type)
if info is None:
return WidgetDataResponse(
widget_id=widget_id,
widget_type=widget_type,
data=None,
error=f"Unknown widget type: {widget_type}",
fetched_at=int(time.time()),
).model_dump()
adapter = get_source_adapter(info.source_type)
if adapter is None:
# Defensive: registry should prevent this, but return a safe error.
return WidgetDataResponse(
widget_id=widget_id,
widget_type=widget_type,
data=None,
error=f"No adapter registered for source type: {info.source_type}",
fetched_at=int(time.time()),
).model_dump()
try:
data = await adapter.fetch(widget["config"])
except Exception as exc:
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,
widget_type=widget_type,
data=data if "error" not in data else None,
error=data.get("error"),
fetched_at=int(time.time()),
).model_dump()