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

10 KiB

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

Issue: #60 — Backlog: browser channel playback after UI redesign 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)

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.