---
key: api.channel-preview-capability
title: 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`.
signals: 'a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60, #552'
mechanics: '`Mapper.GetPreview(StreamingMode, channelNumber, isEnabled, playoutCount)` is pure and JWT-agnostic'
---

`ChannelPreviewAvailability` is one of `Available`, `ForcedHlsOnly`, or `Unavailable`, computed in one
place from the real `StreamingMode` enum plus the channel's enabled/playout state. The SPA renders and
acts on it and derives nothing — deriving it client-side would mean keying behavior off
`Mapper.GetStreamingMode`'s human-readable display label, where a copy tweak would silently break
playback.

`Unavailable` covers two causes, checked in this order (first match wins): the channel is disabled
(`IptvController` 404s a disabled channel, so preview must not even try), and the channel has zero
playouts (a manifest request against one blocks indefinitely). At first pass these two were keying
preview on `StreamingMode` alone, so a disabled or playout-less channel was declared `Available` and
then failed confusingly.

Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
Transport Stream modes serve over `/iptv/*`. Those are declared `ForcedHlsOnly`: preview is offered
only as an explicit opt-in that requests `/iptv/channel/{n}.m3u8?mode=segmenter`, and is always shown
with a caveat that the check does not exercise the channel's configured pipeline. Fatal HLS errors
are reported, never auto-recovered — a diagnostic surface must show the fault rather than retry past
it; a user-initiated Retry re-issues the manifest request via a real `playToken` because the manifest
GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.

Originally, a JWT-enabled deployment made preview `Unavailable` (reason `IPTV JWT authentication is
enabled`) because `/iptv/*` does not accept the SPA's `ctv-session` cookie and nothing minted a JWT
for the browser. #552 closed that: the SPA now mints a short-lived token and appends it as
`?access_token=`, so this projection no longer inspects JWT status at all. See
`security.iptv-browser-token`.
