Files
ersatztv/docs/superpowers/plans/2026-07-21-in-browser-channel-preview.md
T
timothy 11fb60469a docs(60): correct generated-artifact paths in the plan
The real paths are ErsatzTV/wwwroot/openapi/v1.json and
web/src/api/generated/v1.d.ts.

Refs #60
2026-07-21 23:13:08 +02:00

42 KiB
Raw Blame History

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/<Domain>/ 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 <file> | 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:

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/channels responses carrying preview: { availability, manifestUrl, unavailableReason }; the generated v1.d.ts type the SPA imports in Tasks 45.

  • 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: HlsPlayerProps gains onError?: (message: string) => void. Tasks 45 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:

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 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:

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: HlsPlayer with onError (Task 3); SlideOver from web/src/components/overlay.tsx with props { open, onClose, title, subtitle, children, footer, width, style }; the generated preview field (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 owns statesById)
  • Test: web/src/screens/ChannelsScreen.test.tsx

Interfaces:

  • Consumes: ChannelPreviewPanel and FORCED_HLS_CAVEAT (Task 4); channel.preview (Task 2); the existing statesById map (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 12. 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 12. 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.