- 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.
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.jsis already a dependency (web/package.json).web/src/media/HlsPlayer.tsxis a working, reusable, teardown-safe HLS<video>component (hls.js when MSE is available; native HLS on Safari). Consumed today only byPlaybackTroubleshootingScreen. Seedocs/spa-conventions.md§5b.ChannelsScreen.tsxalready 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:
SecurityHeadersMiddlewaresetsdefault-src 'self'(the fallback formedia-src) andconnect-src 'self', andCross-Origin-Resource-Policy: same-origindoes not affect same-origin<video>/MSE loads from/app. GET /api/v1/channels/statealready returnsNowPlaying(Title/StartUtc/FinishUtc) per channel, andChannelsScreenalready 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.m3u8toHlsPlayerwith 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 optionalonErrorcallback wired toHls.Events.ERRORand to the<video>element's ownerrorevent (Safari native path). Today failures are silent, which is disqualifying for a diagnostic tool. Strictly additive; existingPlaybackTroubleshootingScreenusage is unchanged.web/src/screens/channels/ChannelPreviewPanel.tsx— new. ASlideOver(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 fromPreview.Availabilityalone.
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
GET /api/v1/channels— each row already carriesPreview. Play button state comes straight fromPreview.Availability; no extra request, no client-side mode parsing.- Click Play → panel opens with the channel and its
Preview. ForForcedHlsOnlythe primary button is disabled and a secondary "Preview via HLS anyway" action uses the sameManifestUrl. - Panel passes
Preview.ManifestUrltoHlsPlayerassrc, incrementingplayTokenon 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). - "Guide says now" is read from the
ChannelStateResponseModel.NowPlaying(Title/StartUtc/FinishUtc) thatChannelsScreenalready has in hand viastatesById(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. WhenNowPlayingis null the panel shows "Nothing scheduled". - Close/unmount →
HlsPlayertears 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
curloutside the browser. - A
ForcedHlsOnlyresult never reads as validating the configured TS pipeline.
Testing
- Vitest with
hls.jsmocked wholesale perspa-conventions§5b (class exposingstatic isSupported(),static Events,loadSource/attachMedia/on/destroy) so jsdom never touches a realMediaSource; the manifest URL is asserted via theloadSourcespy. Established inPlaybackTroubleshootingScreen.test.tsx. - Panel tests per
Availabilityvalue: button state, URL loaded, caveat banner present only forForcedHlsOnly, error state rendered when the mocked hls.js emits a fatalERROR. PlaybackTroubleshootingScreen's existing test stays green unchanged — the guard that theHlsPlayerextension is genuinely additive.- Server: unit tests over the preview-capability mapper for all four
StreamingModevalues x JWT on/off (8 cases). - Live-E2E via
scripts/e2e-local.shagainst a fresh config dir (testing.e2e-local-fresh-config-dir): create a segmenter channel, confirmGET /api/v1/channelsreturns a usablePreview.ManifestUrl, andcurlthat 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.shthennpm 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.mdchecklist,docs/spa-conventions.md§5b (theonErrorextension), and a decision record for the server-declared preview-capability field.
Follow-ups (separate issues)
- 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. - Selector gap:
scripts/select-queue.shranks Renovate's bot-authored "Dependency Dashboard" (#22) as a workable pickup. It should be excluded.