# New widgets: Jellyfin now_playing + Grafana panel embed

## Files changed (10 files, ~390 lines)

| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/integrations/jellyfin.py` | modified | +12 (new widget kind + config model) |
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +17 (new widget kind + config model) |
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +12 (now_playing filter + panel embed URL) |
| `backend/tests/test_services.py` | modified | +3 (updated widget-kind assertions) |
| `backend/tests/test_widgets.py` | modified | +85 (import + 6 new tests) |
| `frontend/src/widgets/JellyfinNowPlayingWidget.tsx` | new | 41 |
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | new | 50 |
| `frontend/src/integrations/registry.ts` | modified | +24 (2 new widget bindings) |
| `frontend/src/integrations/registry.test.ts` | modified | +1 (updated grafana kinds) |
| `frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx` | new | 72 |
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | new | 72 |

## Session-filter logic for now_playing

```python
if widget_kind == "now_playing":
    sessions = [
        s for s in sessions
        if s.get("NowPlayingItem")
        and not s.get("PlayState", {}).get("IsPaused", True)
    ]
```

Filters raw Jellyfin sessions BEFORE `_map_sessions_to_activity_rows`. A session is "actively playing" when it has a `NowPlayingItem` (something is playing, not just idle) AND `PlayState.IsPaused` is false. The `activity` kind (default) is unchanged — shows all sessions including idle and paused.

## Embed URL format for panel

```python
embed_url = f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv"
```

Uses Grafana's `/d-solo/` endpoint which renders a single panel without dashboard chrome. `kiosk=tv` hides the top nav. Defaults: `from_ts="now-1h"`, `to_ts="now"`.

## Validation

```
cd backend && .venv/bin/ruff check src/ tests/  → All checks passed!
cd backend && .venv/bin/python -m pytest tests/ → 278 passed, 2 warnings (pre-existing)
cd frontend && npm run lint                     → 0 errors, 0 warnings
cd frontend && npm run build                    → ✓ built (tsc + vite)
cd frontend && npm run test                     → 39 files / 127 tests passed
```

Backend: +6 new tests (definition assertions x2, grafana panel URL x2, jellyfin now_playing filter x1, jellyfin activity shows all x1).
Frontend: +6 new tests (JellyfinNowPlayingWidget x3, GrafanaPanelWidget x3).

## Deviations

1. **No deviations from spec.** Both widgets are additive — no existing behavior changed. The `activity` and `link` kinds work exactly as before.
2. **GrafanaPanelWidget pi-lens advisory** for `<Button asChild><a>` is a false positive (Radix Slot merges props, doesn't create nested `<a>`). Same pattern as GrafanaLinkWidget, ObservabilityPage, and PinnedServiceLink. Build and lint pass.

## skill_resolution

`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.

## Residual risks

- **Grafana embedding may be blocked** by `X-Frame-Options` or CSP depending on Grafana config. The fallback "Open in Grafana" link is provided.
- **GrafanaPanelWidget iframe height is fixed at 300px** — not responsive to panel content height. A follow-up could use Grafana's panel-content-height API or a ResizeObserver.
- **now_playing filter operates on raw sessions before mapping** — if Jellyfin changes its session shape (e.g. moves `NowPlayingItem`/`PlayState`), the filter silently passes all sessions. Same fragility as the existing activity mapping.

```acceptance-report
{
  "criteriaSatisfied": [
    {
      "id": "criterion-1",
      "status": "satisfied",
      "evidence": "Implements two additive widget kinds (jellyfin now_playing + grafana panel embed) without changing any existing behavior. Backend: new widget configs + definitions + source adapter logic + 6 tests. Frontend: 2 new components + registry bindings + 6 tests. 278 backend + 127 frontend tests pass; ruff/eslint/tsc/vite all green. No staged files."
    }
  ],
  "changedFiles": [
    "backend/src/media_library_viewer_api/integrations/jellyfin.py",
    "backend/src/media_library_viewer_api/integrations/grafana.py",
    "backend/src/media_library_viewer_api/widgets/sources.py",
    "backend/tests/test_services.py",
    "backend/tests/test_widgets.py",
    "frontend/src/widgets/JellyfinNowPlayingWidget.tsx",
    "frontend/src/widgets/GrafanaPanelWidget.tsx",
    "frontend/src/integrations/registry.ts",
    "frontend/src/integrations/registry.test.ts",
    "frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
    "frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
  ],
  "testsAddedOrUpdated": [
    "backend/tests/test_services.py",
    "backend/tests/test_widgets.py",
    "frontend/src/integrations/registry.test.ts",
    "frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
    "frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
  ],
  "commandsRun": [
    {
      "command": "cd backend && .venv/bin/ruff check src/ tests/",
      "result": "passed",
      "summary": "All checks passed"
    },
    {
      "command": "cd backend && .venv/bin/python -m pytest tests/ -q",
      "result": "passed",
      "summary": "278 passed, 2 warnings (pre-existing deprecation)"
    },
    {
      "command": "cd frontend && npm run lint",
      "result": "passed",
      "summary": "0 errors, 0 warnings"
    },
    {
      "command": "cd frontend && npm run build",
      "result": "passed",
      "summary": "tsc + vite build clean"
    },
    {
      "command": "cd frontend && npm run test",
      "result": "passed",
      "summary": "39 files / 127 tests passed"
    }
  ],
  "validationOutput": [
    "Backend ruff clean; 278 tests pass (+6 new).",
    "Frontend eslint clean; tsc + vite build clean; 127 tests pass (+6 new).",
    "Jellyfin now_playing filters: session has NowPlayingItem + IsPaused=false.",
    "Grafana panel embed URL: /d-solo/{uid}/manage?panelId={id}&from={from}&to={to}&kiosk=tv.",
    "Existing activity + link widget kinds unchanged (tested)."
  ],
  "residualRisks": [
    "Grafana iframe may be blocked by X-Frame-Options/CSP; fallback link provided.",
    "Iframe height fixed at 300px (not responsive to panel content).",
    "now_playing filter depends on Jellyfin session shape (NowPlayingItem/PlayState)."
  ],
  "noStagedFiles": true,
  "diffSummary": "~390 lines across 11 files: 2 new backend widget kinds (jellyfin now_playing + grafana panel) with source adapter logic, 2 new frontend components, registry bindings, and 12 new tests (6 backend + 6 frontend). Purely additive — no existing behavior changed.",
  "reviewFindings": [
    "no blockers"
  ],
  "manualNotes": "The JellyfinClient mock approach uses patch on the class directly (not asyncio.to_thread) — let real asyncio handle the threading. The pi-lens nested-<a> advisory on GrafanaPanelWidget is a false positive (Button asChild uses Radix Slot)."
}
