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.
This commit is contained in:
Developer
2026-06-28 15:26:43 +00:00
parent 8d2e4c9bfd
commit b877a32ad8
11 changed files with 453 additions and 4 deletions
@@ -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,
),
],
)
@@ -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,
),
],
)
@@ -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: