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
- 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.
194 lines
10 KiB
Markdown
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.
|