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:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, ServiceBinding> = {
|
||||
},
|
||||
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<string, ServiceBinding> = {
|
||||
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: {
|
||||
|
||||
@@ -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 (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : embedUrl ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={widget.title}
|
||||
className="h-[300px] w-full rounded-lg border border-border"
|
||||
loading="lazy"
|
||||
/>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={embedUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open in Grafana
|
||||
<ExternalLink className="ml-2 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No Grafana panel configured.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { NowPlayingSession, WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function JellyfinNowPlayingWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : Array.isArray(sessions) ? (
|
||||
<SessionActivityPanel
|
||||
sessions={sessions}
|
||||
emptyMessage="No one is playing right now."
|
||||
/>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { GrafanaPanelWidget } from "../GrafanaPanelWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w2",
|
||||
service_id: "s2",
|
||||
widget_kind: "panel",
|
||||
title: "CPU Usage",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
describe("GrafanaPanelWidget", () => {
|
||||
it("renders an iframe with the embed URL", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w2",
|
||||
data: {
|
||||
embed_url:
|
||||
"http://grafana:3000/d-solo/ov/manage?panelId=4&from=now-1h&to=now&kiosk=tv",
|
||||
},
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
const { container } = render(
|
||||
<GrafanaPanelWidget widget={widget} refreshIntervalMs={0} />,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute("src")).toContain("d-solo/ov/manage");
|
||||
expect(iframe?.getAttribute("src")).toContain("panelId=4");
|
||||
});
|
||||
|
||||
it("renders Open in Grafana fallback link", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w2",
|
||||
data: {
|
||||
embed_url:
|
||||
"http://grafana:3000/d-solo/ov/manage?panelId=4&from=now-1h&to=now&kiosk=tv",
|
||||
},
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(<GrafanaPanelWidget widget={widget} refreshIntervalMs={0} />);
|
||||
expect(screen.getByText("Open in Grafana")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w2",
|
||||
error: "dashboard_uid is required",
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(<GrafanaPanelWidget widget={widget} refreshIntervalMs={0} />);
|
||||
expect(screen.getByText("dashboard_uid is required")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { JellyfinNowPlayingWidget } from "../JellyfinNowPlayingWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "now_playing",
|
||||
title: "Now Playing",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
describe("JellyfinNowPlayingWidget", () => {
|
||||
it("renders sessions when data is present", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w1",
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
user: "alice",
|
||||
title: "Movie",
|
||||
state: "playing",
|
||||
type: "Movie",
|
||||
device: "Web",
|
||||
session_id: "s1",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText("alice")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Movie").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the now-playing empty message when no sessions", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w1",
|
||||
data: { sessions: [] },
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No one is playing right now."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w1",
|
||||
error: "Connection failed",
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText("Connection failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user