# In-Browser Channel Preview Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let an operator verify from the ChicoryTV channels list that a channel is actually streaming right now, and that it is playing what the guide says. **Architecture:** The server declares per-channel preview capability as one additive DTO field (`Preview`) computed from the real `StreamingMode` enum plus `JwtHelper.IsEnabled`; the SPA renders and acts on it without deriving anything. A new `SlideOver` panel drives the existing reusable `HlsPlayer`, which gains error reporting so failures stop being silent. **Tech Stack:** C# / .NET 10, MediatR CQRS, NUnit + Shouldly; React + TypeScript SPA (Vite), hls.js, Vitest + Testing Library. **Spec:** `docs/superpowers/specs/2026-07-21-in-browser-channel-preview-design.md` **Issue:** ersatztv#60 ## Global Constraints - Work in the worktree `/Users/timothy/etv-wt-60` on branch `feat/60-channel-preview`. **Never** commit in `/Users/timothy/ersatztv` (`process.shared-tree-readonly`). - Response DTOs live in `ErsatzTV.Core/Api//` with a file-scoped `#nullable enable` pragma (`api.response-dtos`). - `/api/v1` is **additive-only** (`api.versioning-v1`). Add fields; never change or remove existing ones. - URLs returned to the SPA are rooted and directly usable; the SPA does no path building (`api.artwork-rooted-urls`). - Tests are **NUnit + Shouldly + NSubstitute**. xUnit is not used in this repo. - In web tests, mock `hls.js` wholesale so jsdom never touches a real `MediaSource` (`docs/spa-conventions.md` §5b). - Client-side preference storage, if any, uses `ctv-`-namespaced `localStorage` keys. (This feature stores nothing.) - Before any push touching `.cs`: BOM-check the touched set with `xxd -p | head -c 6 | grep -q '^efbbbf'` and run the format gate under `bash -c` (`process.bom-format-detection-recipe`). - Never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. - Copy rule — the forced-HLS caveat text is used verbatim wherever it appears: `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.` --- ### Task 1: Server — preview capability DTO + mapper **Files:** - Create: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs` - Modify: `ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs` (add field to record) - Modify: `ErsatzTV.Application/Channels/Mapper.cs:99-111` (`ProjectToResponseModel`) and add `GetPreview` - Modify: `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs` - Test: `ErsatzTV.Core.Tests/Api/Channels/ChannelPreviewTests.cs` **Interfaces:** - Consumes: nothing. - Produces: - `ErsatzTV.Core.Api.Channels.ChannelPreviewAvailability` — enum-like string constants: `"Available"`, `"ForcedHlsOnly"`, `"Unavailable"`. - `record ChannelPreviewResponseModel(string Availability, string? ManifestUrl, string? UnavailableReason)`. - `ChannelResponseModel` gains a trailing `ChannelPreviewResponseModel Preview` member. - `ErsatzTV.Application.Channels.Mapper.GetPreview(StreamingMode streamingMode, string channelNumber, bool iptvJwtEnabled) -> ChannelPreviewResponseModel` (internal static, pure). - `Mapper.ProjectToResponseModel(Channel channel, int playoutCount, bool iptvJwtEnabled)` — note the **new third parameter**; Task 2 relies on this signature. `Availability` is serialized as a plain string (not an enum) so the generated TypeScript is a string union and the SPA needs no enum mapping, matching how `StreamingMode` is already exposed. - [ ] **Step 1: Write the failing tests** Create `ErsatzTV.Core.Tests/Api/Channels/ChannelPreviewTests.cs`: ```csharp using ErsatzTV.Application.Channels; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Core.Tests.Api.Channels; [TestFixture] public class ChannelPreviewTests { [TestCase(StreamingMode.HttpLiveStreamingSegmenter)] [TestCase(StreamingMode.HttpLiveStreamingDirect)] public void HlsModes_Are_Available_With_Plain_Manifest_Url(StreamingMode mode) { ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false); result.Availability.ShouldBe("Available"); result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8"); result.UnavailableReason.ShouldBeNull(); } [TestCase(StreamingMode.TransportStream)] [TestCase(StreamingMode.TransportStreamHybrid)] public void TransportStream_Modes_Are_ForcedHlsOnly(StreamingMode mode) { ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false); result.Availability.ShouldBe("ForcedHlsOnly"); result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8?mode=segmenter"); result.UnavailableReason.ShouldBeNull(); } [TestCase(StreamingMode.HttpLiveStreamingSegmenter)] [TestCase(StreamingMode.HttpLiveStreamingDirect)] [TestCase(StreamingMode.TransportStream)] [TestCase(StreamingMode.TransportStreamHybrid)] public void Jwt_Enabled_Makes_Every_Mode_Unavailable(StreamingMode mode) { ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: true); result.Availability.ShouldBe("Unavailable"); result.ManifestUrl.ShouldBeNull(); result.UnavailableReason.ShouldBe("IPTV JWT authentication is enabled"); } [Test] public void Channel_Number_Is_Used_Verbatim_In_The_Manifest_Url() { ChannelPreviewResponseModel result = Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "7", iptvJwtEnabled: false); result.ManifestUrl.ShouldBe("/iptv/channel/7.m3u8"); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `cd /Users/timothy/etv-wt-60 && dotnet test ErsatzTV.Core.Tests --filter ChannelPreviewTests` Expected: FAIL to compile — `Mapper.GetPreview` and `ChannelPreviewResponseModel` do not exist. - [ ] **Step 3: Create the DTO** Create `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`: ```csharp #nullable enable namespace ErsatzTV.Core.Api.Channels; /// Server-declared browser-preview capability for a channel. /// /// The SPA renders and acts on this; it never derives preview eligibility itself (see /// docs/decisions.md, api.healthcheck-remediation-dto for the same pattern). Deriving it in the /// SPA would mean keying behavior off the human-readable StreamingMode label. /// public record ChannelPreviewResponseModel( // One of ChannelPreviewAvailability's values. A plain string (not an enum) so the generated // TypeScript is a string union the SPA can switch on directly. string Availability, // Rooted, directly-usable HLS manifest URL; null when Availability is Unavailable. string? ManifestUrl, // Human-readable reason; non-null only when Availability is Unavailable. string? UnavailableReason); public static class ChannelPreviewAvailability { /// The channel's configured mode is browser-playable; preview exercises the real pipeline. public const string Available = "Available"; /// Configured for Transport Stream; preview must force an HLS session and is content-only. public const string ForcedHlsOnly = "ForcedHlsOnly"; /// Preview cannot run at all (IPTV JWT auth is enabled and the SPA cannot mint a token). public const string Unavailable = "Unavailable"; } ``` - [ ] **Step 4: Add the field to `ChannelResponseModel`** In `ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs`, add a trailing member after `string? Logo`: ```csharp string? Logo, // Server-declared browser-preview capability; see ChannelPreviewResponseModel. ChannelPreviewResponseModel Preview); ``` - [ ] **Step 5: Implement the mapper** In `ErsatzTV.Application/Channels/Mapper.cs`, add next to `GetStreamingMode`: ```csharp internal static ChannelPreviewResponseModel GetPreview( StreamingMode streamingMode, string channelNumber, bool iptvJwtEnabled) { // /iptv/* is gated by ConditionalIptvAuthorizeFilter only when JWT is configured, and the // "jwt" scheme does not accept the SPA's ctv-session cookie. Nothing mints a JWT for the // SPA today, so preview cannot run at all in that configuration. if (iptvJwtEnabled) { return new ChannelPreviewResponseModel( ChannelPreviewAvailability.Unavailable, null, "IPTV JWT authentication is enabled"); } return streamingMode switch { StreamingMode.HttpLiveStreamingSegmenter or StreamingMode.HttpLiveStreamingDirect => new ChannelPreviewResponseModel( ChannelPreviewAvailability.Available, $"/iptv/channel/{channelNumber}.m3u8", null), // A browser cannot play video/mp2t. Forcing ?mode=segmenter yields a playable stream, // but one that does not exercise the channel's configured Transport Stream pipeline — // the SPA labels this result accordingly. StreamingMode.TransportStream or StreamingMode.TransportStreamHybrid => new ChannelPreviewResponseModel( ChannelPreviewAvailability.ForcedHlsOnly, $"/iptv/channel/{channelNumber}.m3u8?mode=segmenter", null), _ => throw new ArgumentOutOfRangeException(nameof(streamingMode)) }; } ``` Add `using ErsatzTV.Core.Api.Channels;` to the file's usings if not already present. - [ ] **Step 6: Thread it through `ProjectToResponseModel`** In `ErsatzTV.Application/Channels/Mapper.cs`, change the signature and the construction: ```csharp internal static ChannelResponseModel ProjectToResponseModel( Channel channel, int playoutCount, bool iptvJwtEnabled) => new( channel.Id, channel.Number, channel.SortNumber, channel.Name, channel.Group, channel.Categories, channel.FFmpegProfile.Name, channel.PreferredAudioLanguageCode, GetStreamingMode(channel), channel.IsEnabled, channel.ShowInEpg, playoutCount, GetLogoUrl(channel), GetPreview(channel.StreamingMode, channel.Number, iptvJwtEnabled)); ``` - [ ] **Step 7: Run the tests to verify they pass** Run: `cd /Users/timothy/etv-wt-60 && dotnet test ErsatzTV.Core.Tests --filter ChannelPreviewTests` Expected: PASS, 9 tests. - [ ] **Step 8: Fix the remaining call site and build** `GetAllChannelsForApiHandler` now fails to compile. It is the only caller of `ProjectToResponseModel`; confirm with: Run: `cd /Users/timothy/etv-wt-60 && grep -rn "ProjectToResponseModel(" --include=*.cs . | grep -i channel` Then update `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs`. `JwtHelper` lives in the `ErsatzTV` host assembly, which `ErsatzTV.Application` does **not** reference, so the flag is carried on the query rather than read statically here — this is also what keeps `GetPreview` pure and testable: ```csharp IEnumerable channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten(); return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), request.IptvJwtEnabled)).ToList(); ``` And add the property to the request record in `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs`: ```csharp public record GetAllChannelsForApi(bool IptvJwtEnabled) : IRequest>; ``` Run: `cd /Users/timothy/etv-wt-60 && dotnet build ErsatzTV.sln` Expected: one remaining error, in `ChannelController.GetAll` — fixed in Task 2. - [ ] **Step 9: Commit** ```bash cd /Users/timothy/etv-wt-60 git add ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs \ ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs \ ErsatzTV.Application/Channels/Mapper.cs \ ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs \ ErsatzTV.Application/Channels/Queries/GetAllChannelsForApi.cs \ ErsatzTV.Core.Tests/Api/Channels/ChannelPreviewTests.cs git commit -m "feat(60): server-declared channel preview capability" ``` --- ### Task 2: Server — wire the flag at the controller and regenerate the API contract **Files:** - Modify: `ErsatzTV/Controllers/Api/ChannelController.cs:27-29` (`GetAll`) - Modify: `ErsatzTV/wwwroot/openapi/v1.json` (generated) - Modify: `docs/endpoint-index.md` (generated) - Modify: `web/src/api/generated/v1.d.ts` (generated) - Modify: `docs/api-conventions.md` **Interfaces:** - Consumes: `GetAllChannelsForApi(bool IptvJwtEnabled)` from Task 1. - Produces: `GET /api/v1/channels` responses carrying `preview: { availability, manifestUrl, unavailableReason }`; the generated `v1.d.ts` type the SPA imports in Tasks 4–5. - [ ] **Step 1: Pass the flag from the host** In `ErsatzTV/Controllers/Api/ChannelController.cs`, `JwtHelper` is in scope (same assembly): ```csharp [HttpGet("/api/v1/channels")] [EndpointGroupName("general")] public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi(JwtHelper.IsEnabled)); ``` - [ ] **Step 2: Build to verify the solution compiles** Run: `cd /Users/timothy/etv-wt-60 && dotnet build ErsatzTV.sln` Expected: Build succeeded, 0 errors. - [ ] **Step 3: Run the full server test suite** Run: `cd /Users/timothy/etv-wt-60 && dotnet test ErsatzTV.sln` Expected: PASS. If any test constructs a `ChannelResponseModel` positionally it will fail to compile — add `GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", false)` as its final argument. - [ ] **Step 4: Regenerate the API artifacts** Order matters — the app project must build first (`process.pr-routine-sequence`): ```bash cd /Users/timothy/etv-wt-60 ./scripts/update-openapi.sh cd web && npm run generate:api ``` - [ ] **Step 5: Verify the generated types contain the new field** Run: `cd /Users/timothy/etv-wt-60 && grep -n "unavailableReason" web/src/api/generated/v1.d.ts ErsatzTV/wwwroot/openapi/v1.json | head` Expected: at least one hit in each file. If `v1.d.ts` has no hit, the regen did not pick up the build — rerun step 4. - [ ] **Step 6: Update the API docs** In `docs/api-conventions.md`, in the section covering response DTOs, add a bullet: ```markdown - **Server-declared capability fields.** When the SPA must decide whether an action is possible, the server declares it as structured metadata on the response DTO (`{Availability, ...}`) rather than the SPA inferring it from a display string. Examples: health-check remediation (`api.healthcheck-remediation-dto`) and channel preview capability (`ChannelPreviewResponseModel`). ``` - [ ] **Step 7: Commit** ```bash cd /Users/timothy/etv-wt-60 git add ErsatzTV/Controllers/Api/ChannelController.cs ErsatzTV/wwwroot/openapi/v1.json \ docs/endpoint-index.md web/src/api/generated/v1.d.ts docs/api-conventions.md git commit -m "feat(60): expose channel preview capability on GET /api/v1/channels" ``` --- ### Task 3: SPA — `HlsPlayer` error reporting **Files:** - Modify: `web/src/media/HlsPlayer.tsx` - Test: `web/src/media/HlsPlayer.test.tsx` (create) - Modify: `docs/spa-conventions.md` §5b **Interfaces:** - Consumes: nothing. - Produces: `HlsPlayerProps` gains `onError?: (message: string) => void`. Tasks 4–5 rely on this exact name and signature. Existing props are unchanged, so `PlaybackTroubleshootingScreen` keeps working untouched. - [ ] **Step 1: Write the failing test** Create `web/src/media/HlsPlayer.test.tsx`: ```tsx import { render } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HlsPlayer } from './HlsPlayer'; const hlsMock = { isSupported: true, loadSource: vi.fn(), attachMedia: vi.fn(), on: vi.fn(), destroy: vi.fn() }; vi.mock('hls.js', () => { class MockHls { static isSupported() { return hlsMock.isSupported; } static Events = { MANIFEST_PARSED: 'hlsManifestParsed', ERROR: 'hlsError' }; loadSource = hlsMock.loadSource; attachMedia = hlsMock.attachMedia; on = hlsMock.on; destroy = hlsMock.destroy; } return { default: MockHls }; }); describe('HlsPlayer', () => { beforeEach(() => { vi.clearAllMocks(); hlsMock.isSupported = true; }); it('reports a fatal hls.js error through onError', () => { const onError = vi.fn(); render(); const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1]; expect(errorHandler).toBeTypeOf('function'); errorHandler({}, { fatal: true, type: 'networkError', details: 'manifestLoadError' }); expect(onError).toHaveBeenCalledWith('networkError: manifestLoadError'); }); it('ignores non-fatal hls.js errors', () => { const onError = vi.fn(); render(); const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1]; errorHandler({}, { fatal: false, type: 'mediaError', details: 'bufferStalledError' }); expect(onError).not.toHaveBeenCalled(); }); it('does not auto-recover a fatal error', () => { render(); const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1]; errorHandler({}, { fatal: true, type: 'networkError', details: 'manifestLoadError' }); // A diagnostic player must surface the fault, not silently retry past it. expect(hlsMock.loadSource).toHaveBeenCalledTimes(1); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/media/HlsPlayer.test.tsx` Expected: FAIL — no `hlsError` handler is registered, so `errorHandler` is `undefined`. - [ ] **Step 3: Implement** In `web/src/media/HlsPlayer.tsx`, add to `HlsPlayerProps`: ```tsx // Called with a human-readable reason when playback fails. Fatal errors are reported, never // auto-recovered: a diagnostic player that silently retries hides the fault it exists to reveal. onError?: (message: string) => void; ``` Add `onError` to the destructured parameters: ```tsx export function HlsPlayer({ src, playToken = 0, className, style, onError }: HlsPlayerProps) { ``` Inside the `Hls.isSupported()` branch, after the `MANIFEST_PARSED` handler: ```tsx hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { onError?.(`${data.type}: ${data.details}`); } }); ``` In the native-HLS (Safari) branch, alongside the `canplay` listener: ```tsx const onVideoError = () => { onError?.('The browser could not play this stream'); }; video.addEventListener('error', onVideoError); ``` and remove it in that branch's cleanup, next to the existing `removeEventListener('canplay', ...)`: ```tsx video.removeEventListener('error', onVideoError); ``` Add `onError` to the effect's dependency array: `}, [src, playToken, onError]);` > Callers must pass a stable `onError` (wrap in `useCallback`) or the effect will re-run and restart the stream on every render. Task 4 does this. - [ ] **Step 4: Run the tests to verify they pass** Run: `cd /Users/timothy/etv-wt-60/web && npx vitest run src/media/HlsPlayer.test.tsx src/screens/PlaybackTroubleshootingScreen.test.tsx` Expected: PASS for both files. The troubleshooting test passing **unchanged** is the proof this extension is additive. - [ ] **Step 5: Update the SPA conventions doc** In `docs/spa-conventions.md` §5b, append to the paragraph: ```markdown It also takes an optional `onError(message)` wired to `Hls.Events.ERROR` (fatal errors only) and, on the Safari native path, the `