From b877a32ad89c7e9c01b074d77442fe8a9534bfec Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 28 Jun 2026 15:26:43 +0000 Subject: [PATCH] Add Jellyfin Now Playing + Grafana Panel embed widgets Two new additive widget kinds: Jellyfin 'now_playing': like the existing 'activity' widget but filters to only sessions with active playback (NowPlayingItem present + not paused). Shows who's actually watching right now. The 'activity' kind is unchanged (shows all sessions including idle). Grafana 'panel': embeds a single Grafana panel directly in the app via an iframe, using Grafana's /d-solo/ endpoint (renders one panel without dashboard chrome, kiosk=tv). Configurable dashboard_uid, panel_id, and time range (from/to, defaults now-1h/now). Includes a fallback 'Open in Grafana' link for when embedding is blocked by X-Frame-Options/CSP. The 'link' kind is unchanged (still builds a deep-link URL). Backend: new widget configs + definitions on jellyfin/grafana; source adapter logic (session filter for now_playing; d-solo embed URL for panel); 6 new tests. Frontend: JellyfinNowPlayingWidget + GrafanaPanelWidget components; registry bindings; 6 new tests. 278 backend tests pass (+6); 127 frontend tests pass (+6); lint/build green both sides. --- .../integrations/grafana.py | 17 +++ .../integrations/jellyfin.py | 14 +++ .../widgets/sources.py | 19 +++- backend/tests/test_services.py | 6 +- backend/tests/test_widgets.py | 105 ++++++++++++++++++ frontend/src/integrations/registry.test.ts | 1 + frontend/src/integrations/registry.ts | 34 ++++++ frontend/src/widgets/GrafanaPanelWidget.tsx | 53 +++++++++ .../src/widgets/JellyfinNowPlayingWidget.tsx | 41 +++++++ .../__tests__/GrafanaPanelWidget.test.tsx | 78 +++++++++++++ .../JellyfinNowPlayingWidget.test.tsx | 89 +++++++++++++++ 11 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 frontend/src/widgets/GrafanaPanelWidget.tsx create mode 100644 frontend/src/widgets/JellyfinNowPlayingWidget.tsx create mode 100644 frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx create mode 100644 frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx diff --git a/backend/src/media_library_viewer_api/integrations/grafana.py b/backend/src/media_library_viewer_api/integrations/grafana.py index 842dea0..2d47bda 100644 --- a/backend/src/media_library_viewer_api/integrations/grafana.py +++ b/backend/src/media_library_viewer_api/integrations/grafana.py @@ -26,6 +26,15 @@ class GrafanaLinkWidgetConfig(WidgetConfigBase): panel_id: int | None = None +class GrafanaPanelWidgetConfig(WidgetConfigBase): + """Embed a single Grafana panel via iframe.""" + + dashboard_uid: str + panel_id: int + from_ts: str = "now-1h" + to_ts: str = "now" + + DEFINITION = ServiceDefinition( service_type="grafana", name="Grafana", @@ -43,5 +52,13 @@ DEFINITION = ServiceDefinition( default_config={"dashboard_uid": ""}, refresh_interval_ms=0, ), + widget_kind( + kind="panel", + name="Panel embed", + description="Embed a Grafana panel directly.", + model_cls=GrafanaPanelWidgetConfig, + default_config={"dashboard_uid": "", "panel_id": 1, "from_ts": "now-1h", "to_ts": "now"}, + refresh_interval_ms=0, + ), ], ) diff --git a/backend/src/media_library_viewer_api/integrations/jellyfin.py b/backend/src/media_library_viewer_api/integrations/jellyfin.py index 4feba14..833c63c 100644 --- a/backend/src/media_library_viewer_api/integrations/jellyfin.py +++ b/backend/src/media_library_viewer_api/integrations/jellyfin.py @@ -36,6 +36,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase): pass +class JellyfinNowPlayingWidgetConfig(WidgetConfigBase): + """Only show sessions with active playback (not idle/paused).""" + + pass + + DEFINITION = ServiceDefinition( service_type="jellyfin", name="Jellyfin", @@ -53,5 +59,13 @@ DEFINITION = ServiceDefinition( default_config={}, refresh_interval_ms=30_000, ), + widget_kind( + kind="now_playing", + name="Now Playing", + description="Only sessions actively playing media.", + model_cls=JellyfinNowPlayingWidgetConfig, + default_config={}, + refresh_interval_ms=30_000, + ), ], ) diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index 10a7ccc..39072c4 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -97,7 +97,7 @@ class StaticWidgetSource: class GrafanaWidgetSource: - """Build a Grafana deep-link (no embedding).""" + """Build a Grafana deep-link or panel embed URL.""" async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: try: @@ -107,6 +107,19 @@ class GrafanaWidgetSource: dashboard_uid = config.get("dashboard_uid") if not dashboard_uid: return {"error": "dashboard_uid is required"} + + if widget_kind == "panel": + panel_id = config.get("panel_id") + if panel_id is None: + return {"error": "panel_id is required"} + from_ts = config.get("from_ts", "now-1h") + to_ts = config.get("to_ts", "now") + embed_url = ( + f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv" + ) + return {"embed_url": embed_url} + + # Default: deep-link url = f"{base_url}/d/{dashboard_uid}" panel_id = config.get("panel_id") if panel_id is not None: @@ -208,6 +221,10 @@ class JellyfinWidgetSource: asyncio.to_thread(client.sessions), timeout=timeout, ) + if widget_kind == "now_playing": + sessions = [ + s for s in sessions if s.get("NowPlayingItem") and not s.get("PlayState", {}).get("IsPaused", True) + ] rows = _map_sessions_to_activity_rows(sessions) return {"sessions": rows} except asyncio.TimeoutError: diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index cfbe6ce..942d1e6 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -99,10 +99,10 @@ def test_authentik_service_definition(): def test_definitions_declare_widget_kinds(): - assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"} + assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "panel"} assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"} assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} - assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"} + assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"} assert get_service_definition("nextcloud").widget_kinds == [] assert get_service_definition("authentik").widget_kinds == [] assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"} @@ -184,7 +184,7 @@ def test_service_type_includes_secret_and_widget_metadata(client): response = client.get("/api/services/types") grafana = next(item for item in response.json() if item["service_type"] == "grafana") assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"] - assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"] + assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link", "panel"] # --------------------------------------------------------------------------- diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index eb269ae..bfef3c8 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -16,6 +16,7 @@ from media_library_viewer_api.widgets.sources import ( AlertmanagerWidgetSource, BackupsWidgetSource, GrafanaWidgetSource, + JellyfinWidgetSource, ServiceRecord, StaticWidgetSource, ) @@ -494,3 +495,107 @@ async def test_ssh_task_adapter_records_history_on_run(client): runs = store.list_service_task_runs(service_id=service["id"]) assert len(runs) == 1 assert runs[0]["status"] == "success" + + +# --------------------------------------------------------------------------- +# New widget kind tests (jellyfin now_playing + grafana panel) +# --------------------------------------------------------------------------- + + +def test_jellyfin_definition_has_now_playing_widget(): + from media_library_viewer_api.integrations.registry import get_service_definition + + definition = get_service_definition("jellyfin") + kinds = {wk.kind for wk in definition.widget_kinds} + assert "now_playing" in kinds + assert "activity" in kinds + + +def test_grafana_definition_has_panel_widget(): + from media_library_viewer_api.integrations.registry import get_service_definition + + definition = get_service_definition("grafana") + kinds = {wk.kind for wk in definition.widget_kinds} + assert "panel" in kinds + assert "link" in kinds + + +@pytest.mark.asyncio +async def test_grafana_adapter_builds_panel_embed_url(): + adapter = GrafanaWidgetSource() + service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"}) + result = await adapter.fetch( + service, + "panel", + {"dashboard_uid": "ov", "panel_id": 4, "from_ts": "now-6h", "to_ts": "now"}, + ) + assert "embed_url" in result + assert result["embed_url"] == ("http://g:3000/d-solo/ov/manage?panelId=4&from=now-6h&to=now&kiosk=tv") + + +@pytest.mark.asyncio +async def test_grafana_adapter_panel_uses_defaults(): + adapter = GrafanaWidgetSource() + service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"}) + result = await adapter.fetch(service, "panel", {"dashboard_uid": "ov", "panel_id": 2}) + assert "from=now-1h" in result["embed_url"] + assert "to=now" in result["embed_url"] + + +@pytest.mark.asyncio +async def test_jellyfin_now_playing_filters_active_sessions(): + """now_playing should exclude idle (no NowPlayingItem) and paused sessions.""" + adapter = JellyfinWidgetSource() + service = ServiceRecord( + id="s", + service_type="jellyfin", + name="jf", + config={"base_url": "http://jf:8096"}, + secrets={"api_key": "k"}, + ) + playing_session = { + "UserName": "alice", + "NowPlayingItem": {"Name": "Movie", "Type": "Movie"}, + "PlayState": {"IsPaused": False}, + "DeviceName": "Web", + } + paused_session = { + "UserName": "bob", + "NowPlayingItem": {"Name": "Show", "Type": "Episode"}, + "PlayState": {"IsPaused": True}, + "DeviceName": "TV", + } + idle_session = { + "UserName": "carol", + "PlayState": {"IsPaused": False}, + "DeviceName": "Phone", + } + mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session]) + with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client): + result = await adapter.fetch(service, "now_playing", {}) + sessions = result["sessions"] + assert len(sessions) == 1 + assert sessions[0]["user"] == "alice" + assert sessions[0]["state"] == "playing" + + +@pytest.mark.asyncio +async def test_jellyfin_activity_shows_all_sessions(): + """activity (default) should include idle and paused sessions.""" + adapter = JellyfinWidgetSource() + service = ServiceRecord( + id="s", + service_type="jellyfin", + name="jf", + config={"base_url": "http://jf:8096"}, + secrets={"api_key": "k"}, + ) + mock_client = SimpleNamespace( + sessions=lambda: [ + {"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}}, + {"UserName": "bob", "PlayState": {"IsPaused": False}}, + ] + ) + with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client): + result = await adapter.fetch(service, "activity", {}) + assert len(result["sessions"]) == 2 diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts index 7f939d7..5030123 100644 --- a/frontend/src/integrations/registry.test.ts +++ b/frontend/src/integrations/registry.test.ts @@ -23,6 +23,7 @@ describe("service registry", () => { it("binds widget kinds per service", () => { expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([ "link", + "panel", ]); expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ "active_alerts", diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 5d1b4d4..7f99416 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -2,7 +2,9 @@ import type { ComponentType } from "react"; import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget"; import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget"; +import { GrafanaPanelWidget } from "../widgets/GrafanaPanelWidget"; import { JellyfinWidget } from "../widgets/JellyfinWidget"; +import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget"; import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget"; import { SshTaskWidget } from "../widgets/SshTaskWidget"; import { StaticWidget } from "../widgets/StaticWidget"; @@ -86,6 +88,29 @@ export const SERVICE_REGISTRY: Record = { }, component: GrafanaLinkWidget, }, + { + kind: "panel", + name: "Panel embed", + description: "Embed a Grafana panel directly.", + refreshIntervalMs: 0, + defaultConfig: { + dashboard_uid: "", + panel_id: 1, + from_ts: "now-1h", + to_ts: "now", + }, + configSchema: { + type: "object", + properties: { + dashboard_uid: { type: "string" }, + panel_id: { type: "integer" }, + from_ts: { type: "string" }, + to_ts: { type: "string" }, + }, + required: ["dashboard_uid", "panel_id"], + }, + component: GrafanaPanelWidget, + }, ], }, prometheus: { @@ -122,6 +147,15 @@ export const SERVICE_REGISTRY: Record = { configSchema: { type: "object", properties: {}, required: [] }, component: JellyfinWidget, }, + { + kind: "now_playing", + name: "Now Playing", + description: "Only sessions actively playing media.", + refreshIntervalMs: 30_000, + defaultConfig: {}, + configSchema: { type: "object", properties: {}, required: [] }, + component: JellyfinNowPlayingWidget, + }, ], }, nextcloud: { diff --git a/frontend/src/widgets/GrafanaPanelWidget.tsx b/frontend/src/widgets/GrafanaPanelWidget.tsx new file mode 100644 index 0000000..d040714 --- /dev/null +++ b/frontend/src/widgets/GrafanaPanelWidget.tsx @@ -0,0 +1,53 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ExternalLink } from "lucide-react"; +import { SectionCard } from "../components/SectionCard"; +import { useWidgetData } from "../hooks/useWidgets"; +import type { WidgetInstance } from "../types"; + +interface Props { + widget: WidgetInstance; + refreshIntervalMs: number; + description?: string; +} + +export function GrafanaPanelWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { + const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); + const embedUrl = data?.data?.embed_url as string | undefined; + + return ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : embedUrl ? ( +
+