The real paths are ErsatzTV/wwwroot/openapi/v1.json and web/src/api/generated/v1.d.ts. Refs #60
42 KiB
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-60on branchfeat/60-channel-preview. Never commit in/Users/timothy/ersatztv(process.shared-tree-readonly). - Response DTOs live in
ErsatzTV.Core/Api/<Domain>/with a file-scoped#nullable enablepragma (api.response-dtos). /api/v1is 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.jswholesale so jsdom never touches a realMediaSource(docs/spa-conventions.md§5b). - Client-side preference storage, if any, uses
ctv--namespacedlocalStoragekeys. (This feature stores nothing.) - Before any push touching
.cs: BOM-check the touched set withxxd -p <file> | head -c 6 | grep -q '^efbbbf'and run the format gate underbash -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 addGetPreview - 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).ChannelResponseModelgains a trailingChannelPreviewResponseModel Previewmember.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:
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:
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
/// <summary>Server-declared browser-preview capability for a channel.</summary>
/// <remarks>
/// 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.
/// </remarks>
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
{
/// <summary>The channel's configured mode is browser-playable; preview exercises the real pipeline.</summary>
public const string Available = "Available";
/// <summary>Configured for Transport Stream; preview must force an HLS session and is content-only.</summary>
public const string ForcedHlsOnly = "ForcedHlsOnly";
/// <summary>Preview cannot run at all (IPTV JWT auth is enabled and the SPA cannot mint a token).</summary>
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:
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:
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:
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:
IEnumerable<Channel> 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:
public record GetAllChannelsForApi(bool IptvJwtEnabled) : IRequest<List<ChannelResponseModel>>;
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
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/channelsresponses carryingpreview: { availability, manifestUrl, unavailableReason }; the generatedv1.d.tstype 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):
[HttpGet("/api/v1/channels")]
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> 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):
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:
- **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
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:
HlsPlayerPropsgainsonError?: (message: string) => void. Tasks 4–5 rely on this exact name and signature. Existing props are unchanged, soPlaybackTroubleshootingScreenkeeps working untouched. -
Step 1: Write the failing test
Create web/src/media/HlsPlayer.test.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(<HlsPlayer onError={onError} src="/iptv/channel/12.1.m3u8" />);
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(<HlsPlayer onError={onError} src="/iptv/channel/12.1.m3u8" />);
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(<HlsPlayer onError={vi.fn()} src="/iptv/channel/12.1.m3u8" />);
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:
// 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:
export function HlsPlayer({ src, playToken = 0, className, style, onError }: HlsPlayerProps) {
Inside the Hls.isSupported() branch, after the MANIFEST_PARSED handler:
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:
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', ...):
video.removeEventListener('error', onVideoError);
Add onError to the effect's dependency array: }, [src, playToken, onError]);
Callers must pass a stable
onError(wrap inuseCallback) 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:
It also takes an optional `onError(message)` wired to `Hls.Events.ERROR` (fatal errors only) and,
on the Safari native path, the `<video>` element's own `error` event. Fatal errors are reported,
never auto-recovered — a diagnostic surface must show the fault rather than retry past it. Pass a
**stable** `onError` (`useCallback`); it is in the attach effect's dependency array, so an unstable
identity restarts the stream every render. When mocking `hls.js` in a test that exercises errors,
include `ERROR` in the mock's `static Events`.
- Step 6: Commit
cd /Users/timothy/etv-wt-60
git add web/src/media/HlsPlayer.tsx web/src/media/HlsPlayer.test.tsx docs/spa-conventions.md
git commit -m "feat(60): report fatal HLS errors from HlsPlayer"
Task 4: SPA — ChannelPreviewPanel
Files:
- Create:
web/src/screens/channels/ChannelPreviewPanel.tsx - Test:
web/src/screens/channels/ChannelPreviewPanel.test.tsx
Interfaces:
- Consumes:
HlsPlayerwithonError(Task 3);SlideOverfromweb/src/components/overlay.tsxwith props{ open, onClose, title, subtitle, children, footer, width, style }; the generatedpreviewfield (Task 2). - Produces:
export interface ChannelPreviewNowPlaying {
title: string;
startUtc: string;
finishUtc: string;
}
export interface ChannelPreviewPanelProps {
open: boolean;
onClose: () => void;
channel: {
id: number;
name: string;
number: string;
streamingMode: string;
preview: { availability: string; manifestUrl: null | string; unavailableReason: null | string };
};
nowPlaying: ChannelPreviewNowPlaying | null;
}
export function ChannelPreviewPanel(props: ChannelPreviewPanelProps): JSX.Element;
export const FORCED_HLS_CAVEAT: string;
Task 5 renders this component and passes nowPlaying from the channel-state data it already holds.
- Step 1: Write the failing tests
Create web/src/screens/channels/ChannelPreviewPanel.test.tsx:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelPreviewPanel } from './ChannelPreviewPanel';
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 };
});
function channel(preview: ChannelPreview, streamingMode = 'HLS Segmenter') {
return { id: 1, name: 'Vaporwave', number: '12.1', preview, streamingMode };
}
interface ChannelPreview {
availability: string;
manifestUrl: null | string;
unavailableReason: null | string;
}
const available: ChannelPreview = {
availability: 'Available',
manifestUrl: '/iptv/channel/12.1.m3u8',
unavailableReason: null
};
const forced: ChannelPreview = {
availability: 'ForcedHlsOnly',
manifestUrl: '/iptv/channel/12.1.m3u8?mode=segmenter',
unavailableReason: null
};
const unavailable: ChannelPreview = {
availability: 'Unavailable',
manifestUrl: null,
unavailableReason: 'IPTV JWT authentication is enabled'
};
describe('ChannelPreviewPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
hlsMock.isSupported = true;
});
it('loads the declared manifest url for an available channel', () => {
render(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8');
});
it('shows the now-playing title it was given', () => {
render(
<ChannelPreviewPanel
channel={channel(available)}
nowPlaying={{ finishUtc: '2026-07-21T21:00:00Z', startUtc: '2026-07-21T20:00:00Z', title: 'Neon Nights' }}
onClose={vi.fn()}
open
/>
);
expect(screen.getByText('Neon Nights')).toBeInTheDocument();
});
it('shows nothing-scheduled when there is no now-playing', () => {
render(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.getByText('Nothing scheduled')).toBeInTheDocument();
});
it('does not autoplay a forced-hls channel and shows the caveat only after opting in', async () => {
const user = userEvent.setup();
render(
<ChannelPreviewPanel
channel={channel(forced, 'MPEG-TS')}
nowPlaying={null}
onClose={vi.fn()}
open
/>
);
expect(hlsMock.loadSource).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: /preview via hls anyway/i }));
expect(hlsMock.loadSource).toHaveBeenCalledWith('/iptv/channel/12.1.m3u8?mode=segmenter');
expect(screen.getByText(/does not exercise|not the channel's configured pipeline/i)).toBeInTheDocument();
});
it('never shows the caveat for an available channel', () => {
render(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.queryByText(/not the channel's configured pipeline/i)).not.toBeInTheDocument();
});
it('renders the server reason and no player when unavailable', () => {
render(
<ChannelPreviewPanel channel={channel(unavailable)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.getByText('IPTV JWT authentication is enabled')).toBeInTheDocument();
expect(hlsMock.loadSource).not.toHaveBeenCalled();
});
it('surfaces a fatal playback error', () => {
render(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
const errorHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsError')?.[1];
errorHandler({}, { details: 'manifestLoadError', fatal: true, type: 'networkError' });
expect(screen.getByText(/networkError: manifestLoadError/)).toBeInTheDocument();
});
it('shows the manifest url so a failure can be reproduced with curl', () => {
render(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(screen.getByText('/iptv/channel/12.1.m3u8')).toBeInTheDocument();
});
});
- Step 2: Run the tests to verify they fail
Run: cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/channels/ChannelPreviewPanel.test.tsx
Expected: FAIL — module ./ChannelPreviewPanel not found.
- Step 3: Implement the panel
Create web/src/screens/channels/ChannelPreviewPanel.tsx:
import { useCallback, useEffect, useState } from 'react';
import { SlideOver } from '../../components/overlay';
import { HlsPlayer } from '../../media/HlsPlayer';
export const FORCED_HLS_CAVEAT =
"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.";
export interface ChannelPreviewNowPlaying {
title: string;
startUtc: string;
finishUtc: string;
}
export interface ChannelPreviewPanelProps {
open: boolean;
onClose: () => void;
channel: {
id: number;
name: string;
number: string;
streamingMode: string;
preview: { availability: string; manifestUrl: null | string; unavailableReason: null | string };
};
nowPlaying: ChannelPreviewNowPlaying | null;
}
type PlaybackState = 'failed' | 'idle' | 'playing' | 'starting';
export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: ChannelPreviewPanelProps) {
const { availability, manifestUrl, unavailableReason } = channel.preview;
const forced = availability === 'ForcedHlsOnly';
// Forced previews are opt-in: they do not exercise the configured pipeline, so they must never
// start on their own. Available channels start immediately — that is the whole point of the panel.
const [started, setStarted] = useState(availability === 'Available');
const [state, setState] = useState<PlaybackState>('idle');
const [error, setError] = useState<null | string>(null);
// Reset when the panel is reopened for a different channel.
useEffect(() => {
setStarted(availability === 'Available');
setState('idle');
setError(null);
}, [availability, channel.id]);
// Stable identity: HlsPlayer keeps onError in its attach effect's dependency array, so an
// inline arrow would tear down and restart the stream on every render.
const onError = useCallback((message: string) => {
setError(message);
setState('failed');
}, []);
const src = started ? manifestUrl : null;
useEffect(() => {
if (src) {
setState('starting');
}
}, [src]);
return (
<SlideOver onClose={onClose} open={open} subtitle={`Channel ${channel.number}`} title={`Preview: ${channel.name}`}>
{availability === 'Unavailable' ? (
<p className="ctv-preview-unavailable">{unavailableReason}</p>
) : (
<>
{forced && started && <p className="ctv-preview-caveat">{FORCED_HLS_CAVEAT}</p>}
{forced && !started && (
<>
<p className="ctv-preview-caveat">{FORCED_HLS_CAVEAT}</p>
<button onClick={() => setStarted(true)} type="button">
Preview via HLS anyway
</button>
</>
)}
{src && (
<HlsPlayer
className="ctv-preview-video"
onError={onError}
src={src}
// The manifest GET starts a server-side session; a repeat play of an identical URL
// must re-issue the request rather than be a state no-op (spa-conventions §5b).
playToken={started ? 1 : 0}
/>
)}
</>
)}
<dl className="ctv-preview-facts">
<dt>Mode</dt>
<dd>{channel.streamingMode}</dd>
<dt>URL</dt>
<dd>{manifestUrl ?? 'None'}</dd>
<dt>State</dt>
<dd>{error ? `Failed — ${error}` : state}</dd>
<dt>Guide says now</dt>
<dd>{nowPlaying ? nowPlaying.title : 'Nothing scheduled'}</dd>
</dl>
</SlideOver>
);
}
- Step 4: Run the tests to verify they pass
Run: cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/channels/ChannelPreviewPanel.test.tsx
Expected: PASS, 8 tests.
- Step 5: Commit
cd /Users/timothy/etv-wt-60
git add web/src/screens/channels/ChannelPreviewPanel.tsx web/src/screens/channels/ChannelPreviewPanel.test.tsx
git commit -m "feat(60): add ChannelPreviewPanel"
Task 5: SPA — activate the Play button on the channels list
Files:
- Modify:
web/src/screens/ChannelsScreen.tsx(ChannelTableRow~:596-615, and the parent that ownsstatesById) - Test:
web/src/screens/ChannelsScreen.test.tsx
Interfaces:
-
Consumes:
ChannelPreviewPanelandFORCED_HLS_CAVEAT(Task 4);channel.preview(Task 2); the existingstatesByIdmap (ChannelsScreen.tsx:209). -
Produces: nothing downstream.
-
Step 1: Write the failing tests
Append to web/src/screens/ChannelsScreen.test.tsx (keep the file's existing mocks and fixture helpers; add preview to whatever channel fixture it already builds):
it('enables preview for an available channel and opens the panel', async () => {
const user = userEvent.setup();
renderChannelsScreen({
channels: [
channelFixture({
name: 'Vaporwave',
preview: { availability: 'Available', manifestUrl: '/iptv/channel/12.1.m3u8', unavailableReason: null }
})
]
});
const play = await screen.findByRole('button', { name: 'Preview Vaporwave' });
expect(play).toBeEnabled();
await user.click(play);
expect(await screen.findByText('Preview: Vaporwave')).toBeInTheDocument();
});
it('disables preview and explains why when the server says unavailable', async () => {
renderChannelsScreen({
channels: [
channelFixture({
name: 'Vaporwave',
preview: {
availability: 'Unavailable',
manifestUrl: null,
unavailableReason: 'IPTV JWT authentication is enabled'
}
})
]
});
const play = await screen.findByRole('button', { name: /IPTV JWT authentication is enabled/ });
expect(play).toBeDisabled();
});
it('offers preview for a transport-stream channel via the forced-hls path', async () => {
renderChannelsScreen({
channels: [
channelFixture({
name: 'Retro TV',
preview: {
availability: 'ForcedHlsOnly',
manifestUrl: '/iptv/channel/12.2.m3u8?mode=segmenter',
unavailableReason: null
},
streamingMode: 'MPEG-TS'
})
]
});
const play = await screen.findByRole('button', { name: /Retro TV/ });
expect(play).toBeEnabled();
});
- Step 2: Run the tests to verify they fail
Run: cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/ChannelsScreen.test.tsx
Expected: FAIL — the Play button is still hardcoded disabled with title Preview unavailable for Vaporwave.
- Step 3: Add panel state to the screen
In ChannelsScreen.tsx, in the component that owns statesById (~:209), add:
const [previewChannelId, setPreviewChannelId] = useState<null | number>(null);
Pass onPreview={setPreviewChannelId} down to each ChannelTableRow, and render the panel once, beside the existing dialogs:
{previewChannelId !== null &&
(() => {
const previewChannel = query.data.channels.find((c) => c.id === previewChannelId);
if (!previewChannel) {
return null;
}
return (
<ChannelPreviewPanel
channel={previewChannel}
nowPlaying={statesById[previewChannelId]?.nowPlaying ?? null}
onClose={() => setPreviewChannelId(null)}
open
/>
);
})()}
Add the import: import { ChannelPreviewPanel } from './channels/ChannelPreviewPanel';
- Step 4: Replace the hardcoded disabled Play button
In ChannelTableRow, replace the existing IconButton at :598-600 with:
<IconButton
disabled={channel.preview.availability === 'Unavailable'}
onClick={() => onPreview(channel.id)}
size="sm"
title={
channel.preview.availability === 'Unavailable'
? `Preview unavailable for ${channel.name} — ${channel.preview.unavailableReason}`
: `Preview ${channel.name}`
}
>
<Play aria-hidden="true" size={15} />
</IconButton>
Add onPreview: (channelId: number) => void; to ChannelTableRow's props interface and to its destructured parameters.
- Step 5: Run the tests to verify they pass
Run: cd /Users/timothy/etv-wt-60/web && npx vitest run src/screens/ChannelsScreen.test.tsx
Expected: PASS.
- Step 6: Run the full web verification gate
Per docs/spa-conventions.md §7:
cd /Users/timothy/etv-wt-60/web
npm run lint && npx tsc --noEmit && npm run test && npm run build
Expected: all four succeed.
- Step 7: Commit
cd /Users/timothy/etv-wt-60
git add web/src/screens/ChannelsScreen.tsx web/src/screens/ChannelsScreen.test.tsx
git commit -m "feat(60): activate channel preview on the channels list"
Task 6: Decision record, parity docs, and the live-E2E gate
Files:
- Modify:
docs/decisions.md - Modify:
docs/blazor-route-parity.md - Modify:
docs/README.md(only if a doc was added or retitled — otherwise leave untouched)
Interfaces:
-
Consumes: everything above.
-
Produces: the merge-ready branch.
-
Step 1: Add the decision record
Append to docs/decisions.md, following the lifecycle schema enforced by scripts/decisions_validate.py (5 metadata fields, plus the Signals: line required since #545 — check a neighbouring recent record and copy its exact field layout before writing):
## 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)
`key: api.channel-preview-capability`
`status: active`
`since: 2026-07-21`
`supersedes: none`
`superseded-by: none`
Whether a channel can be previewed in the browser is declared by the server as an additive
`Preview` field on `ChannelResponseModel` (`{Availability, ManifestUrl, UnavailableReason}`),
computed in one place from the real `StreamingMode` enum plus `JwtHelper.IsEnabled`. 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 silently breaks
playback.
Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
Transport Stream modes serve. Those are declared `ForcedHlsOnly`: preview is offered as an explicit
opt-in that requests `?mode=segmenter` and is always labelled as a content-only check that 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.
`/iptv/*` does not accept the SPA's `ctv-session` cookie, and nothing mints a JWT for the SPA, so
under a JWT-enabled deployment preview is declared `Unavailable` with a reason rather than failing
silently.
**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.
- Step 2: Validate the decision record
Run: cd /Users/timothy/etv-wt-60 && python3 scripts/decisions_validate.py
Expected: exit 0, no errors. If it reports a missing field, copy the field layout from the record immediately above yours.
- Step 3: Update route/capability parity notes
In docs/blazor-route-parity.md, in the Channels section, note that the channels list now offers in-browser preview for HLS-mode channels (a capability with no Blazor predecessor — an addition, not a parity item).
- Step 4: Run the full local gate
cd /Users/timothy/etv-wt-60
dotnet build ErsatzTV.sln && dotnet test ErsatzTV.sln
cd web && npm run lint && npx tsc --noEmit && npm run test && npm run build
Expected: all green.
- Step 5: BOM + format check on the touched C# files
cd /Users/timothy/etv-wt-60
for f in $(git diff --name-only origin/main | grep '\.cs$'); do
xxd -p "$f" | head -c 6 | grep -q '^efbbbf' && echo "BOM: $f"
done
bash -c 'dotnet format whitespace . --folder --include $(git diff --name-only origin/main | grep "\.cs$" | tr "\n" " ") --verify-no-changes'
Expected: no BOM: lines; format reports no changes needed.
- Step 6: Live-E2E against a fresh config dir
Per testing.live-e2e-prepush-timing and testing.e2e-local-fresh-config-dir, this runs before the push:
cd /Users/timothy/etv-wt-60
ETV_CONFIG_DIR=$(mktemp -d) ./scripts/e2e-local.sh
Then, against the running instance, create a segmenter-mode channel and confirm the round trip:
curl -s localhost:8409/api/v1/channels | python3 -m json.tool | grep -A3 '"preview"'
curl -sI "localhost:8409$(curl -s localhost:8409/api/v1/channels | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["preview"]["manifestUrl"])')"
Expected: preview.availability is Available with a rooted manifestUrl; the manifest request returns 200 with Content-Type: application/vnd.apple.mpegurl. Curl it — never open a browser tab (testing.playwright-mcp-download-and-recovery).
- Step 7: Commit
cd /Users/timothy/etv-wt-60
git add docs/decisions.md docs/blazor-route-parity.md
git commit -m "docs(60): record the channel-preview capability decision"
- Step 8: Independent cold-context review before the push
Mandatory here — the diff touches an API response DTO and auth-derived behavior (process.independent-review-rubric). Dispatch a review-only agent with no implementation history, scoped to git diff origin/main. Fold its findings into follow-up commits, then post a Review-verdict: <verdict> @ <head-sha> comment on the PR referencing the current head sha (release.review-verdict-gate).
- Step 9: Push and open the PR
cd /Users/timothy/etv-wt-60
git push -u origin feat/60-channel-preview
Open the PR with fixes #60 in the body, then arm the CI monitor on the head sha immediately (ci.monitor-armed-at-pr-open). Batch any further fixes — a run cannot be cancelled agent-side (ci.batch-pushes-no-cancel-route).
Self-Review
Spec coverage: server-declared Preview field → Task 1–2. HlsPlayer.onError → Task 3. SlideOver panel, forced-HLS opt-in + caveat, visible URL, error states, guide correlation → Task 4. Play button activation → Task 5. Decision record, docs, live-E2E, independent review → Task 6. The two follow-up issues in the spec are filed separately, outside this plan.
Placeholder scan: no TBDs; every code step carries complete code. The one deliberately open step is Task 6 Step 1's instruction to copy the exact decision-record field layout from a neighbouring record — the Signals: requirement landed in #545 and the live format is authoritative over anything transcribed here.
Type consistency: GetPreview(StreamingMode, string, bool) and ProjectToResponseModel(Channel, int, bool) are used with those signatures in Tasks 1–2. onError: (message: string) => void is defined in Task 3 and consumed with that exact signature in Task 4. ChannelPreviewPanelProps as defined in Task 4 matches what Task 5 passes (channel, nowPlaying, onClose, open). FORCED_HLS_CAVEAT matches the Global Constraints copy rule verbatim.