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