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:
+3 -3
View File
@@ -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"]
# ---------------------------------------------------------------------------
+105
View File
@@ -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