Files
ersatztv/docs/superpowers/specs/2026-07-21-in-browser-channel-preview-design.md
T
timothy 6838979780
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(60): re-review fixups for channel preview
- ChannelPreviewPanel: a manual play-button click on a video already
  showing a fatal error was clearing the error, silently hiding the
  fault the panel exists to reveal. onPlaying now ignores the event
  while a fatal error is showing (tracked via a ref, reset in an
  effect keyed on channel.id); Retry remains the only way to clear it.
- shell.css: .ctv-preview-facts spacing was dead — equal-specificity
  .ctv-detail-infogrid{margin:0} later in the file won. Raised
  specificity with a compound selector instead of touching
  .ctv-detail-infogrid, which MediaDetailScreen also relies on.
- ChannelPreviewTests: added two cases exercising two simultaneously-
  true Unavailable causes, so the documented guard precedence in
  Mapper.GetPreview is actually pinned by a test.
- design doc: fixed a garbled sentence describing which DTO gained
  the Preview field.
2026-07-21 23:13:27 +02:00

194 lines
10 KiB
Markdown

# 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.