# In-browser channel preview (ersatztv#60) — design

**Issue:** [#60 — Backlog: browser channel playback after UI redesign](http://192.168.1.95:3000/timothy/ersatztv/issues/60)
**Date:** 2026-07-21
**Status:** approved, ready for implementation planning

## Purpose

Give an operator a way to answer, from inside ChicoryTV, **"does this channel actually work
right now, and is it playing what the guide says?"** — without opening Jellyfin, Kodi, or
Dispatcharr.

This is a **verification tool**, not a viewing experience. Every design choice below resolves in
favour of diagnostic honesty over watchability: failures are surfaced rather than recovered, and a
green preview is only ever claimed for the pipeline that was actually exercised.

Explicitly out of scope: channel switching, fullscreen/lean-back UX, resume, a guide-driven
"watch" surface. If a real viewing experience is wanted later it is a separate issue built on this
component.

## Prior art already in the repo

Recon established that most of the machinery exists:

- `hls.js` is already a dependency (`web/package.json`).
- `web/src/media/HlsPlayer.tsx` is a working, reusable, teardown-safe HLS `<video>` component
  (hls.js when MSE is available; native HLS on Safari). Consumed today only by
  `PlaybackTroubleshootingScreen`. See `docs/spa-conventions.md` §5b.
- `ChannelsScreen.tsx` already renders a **permanently-disabled Play button** per row
  (`:598`, title `"Preview unavailable for {name}"`) — this feature's socket, pre-wired.
- CSP/CORP need no change: `SecurityHeadersMiddleware` sets `default-src 'self'` (the fallback for
  `media-src`) and `connect-src 'self'`, and `Cross-Origin-Resource-Policy: same-origin` does not
  affect same-origin `<video>`/MSE loads from `/app`.
- `GET /api/v1/channels/state` already returns `NowPlaying` (`Title`/`StartUtc`/`FinishUtc`) per
  channel, and `ChannelsScreen` already fetches and renders it in its "Now" column
  (`ChannelsScreen.tsx:209,585`) — so the guide correlation needs no new endpoint, no new fetch,
  and no timer.

## Constraints discovered during design

### Only two of four streaming modes are browser-playable

A `<video>` element cannot play `video/mp2t`.

| `StreamingMode` | URL | Browser-playable |
|---|---|---|
| `HttpLiveStreamingSegmenter` | `/iptv/channel/{n}.m3u8` → session variant | yes (HLS) |
| `HttpLiveStreamingDirect` | `/iptv/channel/{n}.m3u8` | yes (HLS) |
| `TransportStreamHybrid` | `/iptv/channel/{n}.ts` | no (raw MPEG-TS) |
| `TransportStream` | `/iptv/channel/{n}.ts` | no (raw MPEG-TS) |

### `/iptv/*` does not accept the SPA session cookie

`ConditionalIptvAuthorizeFilter` no-ops unless `JwtHelper.IsEnabled` (i.e. `JWT:IssuerSigningKey`
is configured) — so `/iptv/*` is **anonymous by default**. When JWT *is* enabled, the `"jwt"`
scheme accepts only a bearer token or `?access_token=`; the SPA's `ctv-session` cookie is a
distinct scheme and does not satisfy it, and **nothing in the app currently mints a JWT for the
SPA**. Preview must therefore degrade visibly under that configuration rather than fail silently.

> This same latent hole affects the **existing** troubleshooting preview, which feeds
> `/iptv/session/.troubleshooting/live.m3u8` to `HlsPlayer` with no token. Out of scope here;
> tracked as a follow-up issue.

### Streaming mode is exposed to the SPA only as a display string

`ChannelResponseModel.StreamingMode` is a human label (`"HLS Segmenter"`, `"MPEG-TS (Legacy)"`)
produced by `ErsatzTV.Application/Channels/Mapper.GetStreamingMode`. Deriving preview eligibility
from it in the SPA would let a copy tweak silently break the player.

## Design

### Server: one additive, server-declared capability field

Following `api.healthcheck-remediation-dto` (server-declared metadata on an additive DTO field;
the SPA renders and acts on it, it does not derive labels itself) and `api.artwork-rooted-urls`
(rooted, directly-usable URLs):

`ChannelResponseModel` (the list DTO the SPA previews from) gains a `Preview` field.
`ChannelDetailResponseModel` deliberately does not — nothing consumes it there, so it stays out
rather than being added speculatively.

```
Preview: {
  Availability:      "Available" | "ForcedHlsOnly" | "Unavailable"
  ManifestUrl:       string?   // rooted and ready to use, e.g. "/iptv/channel/12.1.m3u8"
                               // or "/iptv/channel/12.1.m3u8?mode=segmenter" for ForcedHlsOnly
  UnavailableReason: string?   // e.g. "IPTV JWT authentication is enabled"
}
```

Computed server-side in one place from `JwtHelper.IsEnabled` and the real `StreamingMode` enum:

| Condition | `Availability` | `ManifestUrl` |
|---|---|---|
| `JwtHelper.IsEnabled` | `Unavailable` | `null` |
| `HttpLiveStreamingSegmenter` / `HttpLiveStreamingDirect` | `Available` | `/iptv/channel/{n}.m3u8` |
| `TransportStream` / `TransportStreamHybrid` | `ForcedHlsOnly` | `/iptv/channel/{n}.m3u8?mode=segmenter` |

Additive-only, consistent with `api.versioning-v1`. This is the single source of truth for preview
capability; **no** `IptvJwtEnabled` flag is added to `AuthConfigResponse`.

### SPA

- **`web/src/media/HlsPlayer.tsx`** — gains an optional `onError` callback wired to
  `Hls.Events.ERROR` and to the `<video>` element's own `error` event (Safari native path). Today
  failures are silent, which is disqualifying for a diagnostic tool. Strictly additive; existing
  `PlaybackTroubleshootingScreen` usage is unchanged.
- **`web/src/screens/channels/ChannelPreviewPanel.tsx`** — new. A `SlideOver`
  (`spa.autotune-detailpanel-slideover`) owning preview state, the player, the diagnostic readout,
  and the guide correlation. Understandable and testable without the channels list.
- **`ChannelsScreen.tsx`** — the existing disabled Play button becomes live and opens the panel;
  its state derives from `Preview.Availability` alone.

### Panel contents

```
Preview: 12.1 Vaporwave                    [x]
+--------------------------------------+
|             [ video ]                |
+--------------------------------------+
Mode:            HLS Segmenter
URL:             /iptv/channel/12.1.m3u8   [copy]
State:           * playing
Guide says now:  "Neon Nights"  20:00-21:00
```

For `ForcedHlsOnly`, a persistent caveat banner accompanies every result:

> This channel is configured for Transport Stream, which browsers cannot play. This preview forces
> an HLS segmenter session — it checks the content, **not** the channel's configured pipeline.

### Data flow

1. `GET /api/v1/channels` — each row already carries `Preview`. Play button state comes straight
   from `Preview.Availability`; no extra request, no client-side mode parsing.
2. Click Play → panel opens with the channel and its `Preview`. For `ForcedHlsOnly` the primary
   button is disabled and a secondary "Preview via HLS anyway" action uses the same `ManifestUrl`.
3. Panel passes `Preview.ManifestUrl` to `HlsPlayer` as `src`, incrementing `playToken` on each
   play — the segmenter manifest GET **starts a server-side session**, so a repeat play of an
   identical URL must re-issue the request rather than be a state no-op (`spa-conventions` §5b).
4. "Guide says now" is read from the `ChannelStateResponseModel.NowPlaying`
   (`Title`/`StartUtc`/`FinishUtc`) that `ChannelsScreen` **already** has in hand via `statesById`
   (`ChannelsScreen.tsx:209`) and already renders in its "Now" column. It is passed to the panel as
   a prop. **No new fetch and no timer** — the panel re-renders whenever the screen's existing
   channel-state query refreshes. When `NowPlaying` is null the panel shows "Nothing scheduled".
5. Close/unmount → `HlsPlayer` tears down the hls.js instance (existing behavior), ending segment
   fetches.

### Error handling

- Player states are explicit and plain-language: `idle -> starting -> playing -> stalled ->
  failed(reason)`.
- Fatal hls.js errors are **reported, not auto-recovered**. A diagnostic tool that silently retries
  hides the fault it exists to reveal.
- The manifest URL in play is always visible and copyable, so any failure can be reproduced with
  `curl` outside the browser.
- A `ForcedHlsOnly` result never reads as validating the configured TS pipeline.

## Testing

- **Vitest with `hls.js` mocked wholesale** per `spa-conventions` §5b (class exposing
  `static isSupported()`, `static Events`, `loadSource`/`attachMedia`/`on`/`destroy`) so jsdom
  never touches a real `MediaSource`; the manifest URL is asserted via the `loadSource` spy.
  Established in `PlaybackTroubleshootingScreen.test.tsx`.
- Panel tests per `Availability` value: button state, URL loaded, caveat banner present only for
  `ForcedHlsOnly`, error state rendered when the mocked hls.js emits a fatal `ERROR`.
- `PlaybackTroubleshootingScreen`'s existing test stays green unchanged — the guard that the
  `HlsPlayer` extension is genuinely additive.
- **Server:** unit tests over the preview-capability mapper for all four `StreamingMode` values x
  JWT on/off (8 cases).
- **Live-E2E** via `scripts/e2e-local.sh` against a **fresh config dir**
  (`testing.e2e-local-fresh-config-dir`): create a segmenter channel, confirm `GET
  /api/v1/channels` returns a usable `Preview.ManifestUrl`, and `curl` that manifest to confirm a
  real playlist comes back. Runs **before** the push (`testing.live-e2e-prepush-timing`).

## Process obligations

- Touches `ErsatzTV.Core/Api/**` → regenerate OpenAPI artifacts (`v1.json`, `v1.d.ts`,
  `endpoint-index.md`) via `./scripts/update-openapi.sh` then `npm run generate:api`, in the same
  diff (`release.api-contract-ci-gate`). Build the app project **first**
  (`process.pr-routine-sequence`).
- Mandatory independent cold-context review before push (`process.independent-review-rubric`) —
  API DTO surface plus auth-config-derived behavior.
- Docs updated in the same PR: `docs/api-conventions.md` checklist, `docs/spa-conventions.md` §5b
  (the `onError` extension), and a decision record for the server-declared preview-capability
  field.

## Follow-ups (separate issues)

1. **Mint a short-lived JWT for the SPA** so `/iptv/*` preview and the existing troubleshooting
   screen work under a JWT-enabled deployment. New auth surface; needs its own security review.
2. **Selector gap:** `scripts/select-queue.sh` ranks Renovate's bot-authored "Dependency Dashboard"
   (#22) as a workable pickup. It should be excluded.
