# Channel State API 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:** Add `GET /api/channels/state` so the SPA can poll channel on-air state and current scheduled now-playing data in one batched request.

**Architecture:** Keep `GET /api/channels` static. Add a new channel-state response model in `ErsatzTV.Core.Api.Channels`, a MediatR query/handler in `ErsatzTV.Application.Channels`, and a read-only controller action in `ErsatzTV/Controllers/Api/ChannelController.cs`. The handler reads current playout items in one batched EF query and uses `IFFmpegSegmenterService.IsActive(channelNumber)` for the segmenter-backed `onAir` signal.

**Tech Stack:** C# / .NET 10, ASP.NET Core controllers, MediatR, EF Core, NUnit, Shouldly, NSubstitute, in-memory SQLite test harness.

---

## File Structure

- Create `ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs`
  - Holds current scheduled title and UTC bounds.
- Create `ErsatzTV.Core/Api/Channels/ChannelStateResponseModel.cs`
  - Holds channel id, number, `onAir`, and nullable `nowPlaying`.
- Create `ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs`
  - MediatR request returning `List<ChannelStateResponseModel>`.
- Create `ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs`
  - Batched database query plus segmenter-session join.
- Modify `ErsatzTV.Application/Playouts/Mapper.cs`
  - Make `GetDisplayTitle` public/internal-accessible to the channel handler. Prefer `public static` on the existing method; no behavior change.
- Modify `ErsatzTV/Controllers/Api/ChannelController.cs`
  - Add `GET /api/channels/state`.
- Modify `ErsatzTV.Tests/Controllers/ChannelControllerTests.cs`
  - Add route and mediator mapping tests for the endpoint.
- Create `ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs`
  - Cover `onAir`, `nowPlaying`, null state, and multi-channel behavior.
- Modify `ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs`
  - Add static OpenAPI schema/route coverage for `ChannelStateResponseModel`.
- Modify `ErsatzTV/wwwroot/openapi/v1.json`
  - Regenerate the static OpenAPI document with `scripts/update-openapi.sh`.

## Task 1: Response Models And Controller Route

**Files:**
- Create: `ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs`
- Create: `ErsatzTV.Core/Api/Channels/ChannelStateResponseModel.cs`
- Create: `ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs`
- Modify: `ErsatzTV/Controllers/Api/ChannelController.cs`
- Modify: `ErsatzTV.Tests/Controllers/ChannelControllerTests.cs`

- [ ] **Step 1: Write failing controller test**

Add this test to `ErsatzTV.Tests/Controllers/ChannelControllerTests.cs`:

```csharp
[Test]
public async Task GetState_Should_Return_200_With_Channel_State()
{
    var state = new ChannelStateResponseModel(
        7,
        "7.1",
        true,
        new ChannelNowPlayingResponseModel(
            "Retro Cartoons",
            new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero),
            new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero)));
    _mediator.Send(Arg.Any<GetChannelStatesForApi>(), Arg.Any<CancellationToken>())
        .Returns([state]);

    IActionResult result = await _controller.GetState(CancellationToken.None);

    result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new List<ChannelStateResponseModel> { state });
}
```

Add these `using` directives if needed:

```csharp
using ErsatzTV.Core.Api.Channels;
```

- [ ] **Step 2: Run controller test to verify RED**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~ChannelControllerTests.GetState_Should_Return_200_With_Channel_State" -v minimal
```

Expected: FAIL because `ChannelStateResponseModel`, `ChannelNowPlayingResponseModel`, `GetChannelStatesForApi`, and `ChannelController.GetState` do not exist.

- [ ] **Step 3: Add response models and query**

Create `ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs`:

```csharp
namespace ErsatzTV.Core.Api.Channels;

public record ChannelNowPlayingResponseModel(
    string Title,
    DateTimeOffset StartUtc,
    DateTimeOffset FinishUtc);
```

Create `ErsatzTV.Core/Api/Channels/ChannelStateResponseModel.cs`:

```csharp
namespace ErsatzTV.Core.Api.Channels;

public record ChannelStateResponseModel(
    int ChannelId,
    string ChannelNumber,
    bool OnAir,
    ChannelNowPlayingResponseModel NowPlaying);
```

`ErsatzTV.Core` has nullable annotations disabled, so keep the record signature consistent with nearby API models. The `NowPlaying` value is intentionally allowed to be `null` at runtime and serialized as `nowPlaying: null`.

Create `ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs`:

```csharp
using ErsatzTV.Core.Api.Channels;

namespace ErsatzTV.Application.Channels;

public record GetChannelStatesForApi : IRequest<List<ChannelStateResponseModel>>;
```

- [ ] **Step 4: Add controller endpoint**

In `ErsatzTV/Controllers/Api/ChannelController.cs`, add this action after `GetAll`:

```csharp
[HttpGet("/api/channels/state")]
[Tags("Channels")]
[EndpointSummary("Get channel runtime state")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ChannelStateResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetState(CancellationToken cancellationToken)
{
    List<ChannelStateResponseModel> result =
        await mediator.Send(new GetChannelStatesForApi(), cancellationToken);
    return new OkObjectResult(result);
}
```

- [ ] **Step 5: Run controller test to verify GREEN**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~ChannelControllerTests.GetState_Should_Return_200_With_Channel_State" -v minimal
```

Expected: PASS.

- [ ] **Step 6: Commit Task 1**

```bash
git add ErsatzTV.Core/Api/Channels/ChannelNowPlayingResponseModel.cs ErsatzTV.Core/Api/Channels/ChannelStateResponseModel.cs ErsatzTV.Application/Channels/Queries/GetChannelStatesForApi.cs ErsatzTV/Controllers/Api/ChannelController.cs ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
git commit -m "feat(api): add channel state endpoint contract"
```

## Task 2: Batched Channel State Handler

**Files:**
- Create: `ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs`
- Create: `ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs`
- Modify: `ErsatzTV.Application/Playouts/Mapper.cs`

- [ ] **Step 1: Write failing handler tests**

Create `ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs`:

```csharp
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Application.Channels;

[TestFixture]
public class GetChannelStatesForApiHandlerTests
{
    private InMemoryTvContext _db = null!;
    private IFFmpegSegmenterService _segmenter = null!;

    [SetUp]
    public async Task SetUp()
    {
        _db = await InMemoryTvContext.CreateAsync();
        _segmenter = Substitute.For<IFFmpegSegmenterService>();
    }

    [TearDown]
    public async Task TearDown() => await _db.DisposeAsync();

    [Test]
    public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying()
    {
        DateTime start = DateTime.UtcNow.AddMinutes(-10);
        DateTime finish = DateTime.UtcNow.AddMinutes(20);
        await SeedChannelWithMovie(start, finish);
        _segmenter.IsActive("7.1").Returns(true);
        var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);

        List<ChannelStateResponseModel> result =
            await handler.Handle(new GetChannelStatesForApi(), CancellationToken.None);

        ChannelStateResponseModel state = result.ShouldHaveSingleItem();
        state.ChannelId.ShouldBe(7);
        state.ChannelNumber.ShouldBe("7.1");
        state.OnAir.ShouldBeTrue();
        state.NowPlaying.ShouldNotBeNull();
        state.NowPlaying.Title.ShouldBe("Retro Cartoons");
        state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
        state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
    }

    [Test]
    public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item()
    {
        await using TvContext context = _db.CreateContext();
        context.Channels.Add(MakeChannel(8, "8"));
        await context.SaveChangesAsync();
        var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);

        List<ChannelStateResponseModel> result =
            await handler.Handle(new GetChannelStatesForApi(), CancellationToken.None);

        ChannelStateResponseModel state = result.ShouldHaveSingleItem();
        state.OnAir.ShouldBeFalse();
        state.NowPlaying.ShouldBeNull();
    }

    private async Task SeedChannelWithMovie(DateTime start, DateTime finish)
    {
        await using TvContext context = _db.CreateContext();
        Channel channel = MakeChannel(7, "7.1");
        var movie = new Movie
        {
            Id = 10,
            MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }]
        };
        var playout = new Playout
        {
            Id = 20,
            Channel = channel,
            ChannelId = channel.Id,
            Items = []
        };
        var item = new PlayoutItem
        {
            Id = 30,
            MediaItem = movie,
            MediaItemId = movie.Id,
            Playout = playout,
            PlayoutId = playout.Id,
            Start = start,
            Finish = finish,
            ChapterTitle = string.Empty
        };

        context.Channels.Add(channel);
        context.Movies.Add(movie);
        context.Playouts.Add(playout);
        context.PlayoutItems.Add(item);
        await context.SaveChangesAsync();
    }

    private static Channel MakeChannel(int id, string number) =>
        new(Guid.NewGuid())
        {
            Id = id,
            Number = number,
            SortNumber = id,
            Name = $"Channel {number}",
            Group = "Test",
            Categories = string.Empty,
            PreferredAudioLanguageCode = string.Empty
        };
}
```

- [ ] **Step 2: Run handler tests to verify RED**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~GetChannelStatesForApiHandlerTests" -v minimal
```

Expected: FAIL because `GetChannelStatesForApiHandler` does not exist and `Playouts.Mapper.GetDisplayTitle` is not accessible from the channel handler.

- [ ] **Step 3: Make playout title mapper accessible**

In `ErsatzTV.Application/Playouts/Mapper.cs`, change:

```csharp
internal static class Mapper
```

to:

```csharp
public static class Mapper
```

Then change:

```csharp
internal static string GetDisplayTitle(MediaItem mediaItem, Option<string> maybeChapterTitle)
```

to:

```csharp
public static string GetDisplayTitle(MediaItem mediaItem, Option<string> maybeChapterTitle)
```

- [ ] **Step 4: Implement the handler**

Create `ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs`:

```csharp
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;

namespace ErsatzTV.Application.Channels;

public class GetChannelStatesForApiHandler(
    IDbContextFactory<TvContext> dbContextFactory,
    IFFmpegSegmenterService ffmpegSegmenterService)
    : IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
{
    public async Task<List<ChannelStateResponseModel>> Handle(
        GetChannelStatesForApi request,
        CancellationToken cancellationToken)
    {
        await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
        DateTime now = DateTime.UtcNow;

        List<Channel> channels = await dbContext.Channels
            .AsNoTracking()
            .OrderBy(c => c.SortNumber)
            .ThenBy(c => c.Number)
            .ToListAsync(cancellationToken);

        int[] channelIds = channels.Map(c => c.Id).ToArray();

        List<PlayoutItem> currentItems = await dbContext.PlayoutItems
            .AsNoTracking()
            .Include(pi => pi.Playout)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as Episode).EpisodeMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as Episode).Season)
            .ThenInclude(s => s.Show)
            .ThenInclude(s => s.ShowMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as Movie).MovieMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as MusicVideo).Artist)
            .ThenInclude(a => a.ArtistMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as Song).SongMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as Image).ImageMetadata)
            .Include(pi => pi.MediaItem)
            .ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
            .Where(pi => channelIds.Contains(pi.Playout.ChannelId))
            .Where(pi => pi.Start <= now && pi.Finish > now)
            .OrderBy(pi => pi.Start)
            .ToListAsync(cancellationToken);

        Dictionary<int, PlayoutItem> currentItemsByChannelId = currentItems
            .GroupBy(pi => pi.Playout.ChannelId)
            .ToDictionary(g => g.Key, g => g.First());

        return channels
            .Map(channel =>
            {
                currentItemsByChannelId.TryGetValue(channel.Id, out PlayoutItem playoutItem);

                ChannelNowPlayingResponseModel nowPlaying = playoutItem is null
                    ? null
                    : new ChannelNowPlayingResponseModel(
                        PlayoutMapper.GetDisplayTitle(playoutItem.MediaItem, Optional(playoutItem.ChapterTitle)),
                        new DateTimeOffset(playoutItem.Start, TimeSpan.Zero),
                        new DateTimeOffset(playoutItem.Finish, TimeSpan.Zero));

                return new ChannelStateResponseModel(
                    channel.Id,
                    channel.Number,
                    ffmpegSegmenterService.IsActive(channel.Number),
                    nowPlaying);
            })
            .ToList();
    }
}
```

- [ ] **Step 5: Run handler tests to verify GREEN**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~GetChannelStatesForApiHandlerTests" -v minimal
```

Expected: PASS.

- [ ] **Step 6: Add title coverage tests for another media subtype**

Add one more handler test using `RemoteStream` and `RemoteStreamMetadata` so the include pattern is not movie-only:

```csharp
[Test]
public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title()
{
    DateTime start = DateTime.UtcNow.AddMinutes(-10);
    DateTime finish = DateTime.UtcNow.AddMinutes(20);

    await using TvContext context = _db.CreateContext();
    Channel channel = MakeChannel(9, "9");
    var remoteStream = new RemoteStream
    {
        Id = 90,
        RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }]
    };
    var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] };
    var item = new PlayoutItem
    {
        Id = 92,
        MediaItem = remoteStream,
        MediaItemId = remoteStream.Id,
        Playout = playout,
        PlayoutId = playout.Id,
        Start = start,
        Finish = finish,
        ChapterTitle = string.Empty
    };

    context.Channels.Add(channel);
    context.RemoteStreams.Add(remoteStream);
    context.Playouts.Add(playout);
    context.PlayoutItems.Add(item);
    await context.SaveChangesAsync();

    var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);

    ChannelStateResponseModel state = (await handler.Handle(new GetChannelStatesForApi(), CancellationToken.None))
        .ShouldHaveSingleItem();

    state.NowPlaying.ShouldNotBeNull();
    state.NowPlaying.Title.ShouldBe("Live Source");
}
```

Keep one batched playout-item query. Do not add per-channel database calls.

- [ ] **Step 7: Re-run handler tests after refactor**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~GetChannelStatesForApiHandlerTests" -v minimal
```

Expected: PASS.

- [ ] **Step 8: Commit Task 2**

```bash
git add ErsatzTV.Application/Channels/Queries/GetChannelStatesForApiHandler.cs ErsatzTV.Application/Playouts/Mapper.cs ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs
git commit -m "feat(api): return channel runtime state"
```

## Task 3: OpenAPI Contract

**Files:**
- Modify: `ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs`
- Modify: `ErsatzTV/wwwroot/openapi/v1.json`

- [ ] **Step 1: Write failing static OpenAPI test**

Add this test to `ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs`:

```csharp
[Test]
public void Static_OpenApi_Should_Document_Channel_State_Response()
{
    using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));

    JsonElement getState = document.RootElement
        .GetProperty("paths")
        .GetProperty("/api/channels/state")
        .GetProperty("get");

    JsonElement schema = getState
        .GetProperty("responses")
        .GetProperty("200")
        .GetProperty("content")
        .GetProperty("application/json")
        .GetProperty("schema");

    schema.GetProperty("type").GetString().ShouldBe("array");
    schema.GetProperty("items").GetProperty("$ref").GetString()
        .ShouldBe("#/components/schemas/ChannelStateResponseModel");

    JsonElement stateProperties = document.RootElement
        .GetProperty("components")
        .GetProperty("schemas")
        .GetProperty("ChannelStateResponseModel")
        .GetProperty("properties");

    stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue();
    stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue();
    stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue();
    stateProperties.TryGetProperty("nowPlaying", out _).ShouldBeTrue();
}
```

- [ ] **Step 2: Run OpenAPI test to verify RED**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~OpenApiErrorResponseContractTests.Static_OpenApi_Should_Document_Channel_State_Response" -v minimal
```

Expected: FAIL because the static OpenAPI document does not contain `/api/channels/state`.

- [ ] **Step 3: Update static OpenAPI document**

Run:

```bash
scripts/update-openapi.sh
```

Expected generated changes in `ErsatzTV/wwwroot/openapi/v1.json`:

- `paths["/api/channels/state"].get`
- `components.schemas.ChannelStateResponseModel`
- `components.schemas.ChannelNowPlayingResponseModel`

Ensure JSON property names are camelCase: `channelId`, `channelNumber`, `onAir`, `nowPlaying`, `title`, `startUtc`, `finishUtc`.

- [ ] **Step 4: Run OpenAPI test to verify GREEN**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~OpenApiErrorResponseContractTests.Static_OpenApi_Should_Document_Channel_State_Response" -v minimal
```

Expected: PASS.

- [ ] **Step 5: Commit Task 3**

```bash
git add ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs ErsatzTV/wwwroot/openapi/v1.json
git commit -m "docs(api): document channel state endpoint"
```

## Task 4: Verification And Issue Closeout

**Files:**
- No new files expected.

- [ ] **Step 1: Run focused controller and handler tests**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter "FullyQualifiedName~ChannelControllerTests|FullyQualifiedName~GetChannelStatesForApiHandlerTests|FullyQualifiedName~OpenApiErrorResponseContractTests" -v minimal
```

Expected: PASS.

- [ ] **Step 2: Run broader build/test check**

Run:

```bash
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj -v minimal
```

Expected: PASS.

- [ ] **Step 3: Inspect final diff**

Run:

```bash
git status --short
git diff --stat HEAD~3..HEAD
```

Expected: only #97-related code, tests, static OpenAPI, and docs are changed.

- [ ] **Step 4: Use done skill**

Run the `done` skill for `#97`. Include:

- Root cause: not a bug; backend endpoint did not exist yet.
- What changed: batched channel state endpoint, response DTOs, handler, tests, OpenAPI.
- Caveat: `onAir` is segmenter-only for now.
- Follow-up: #99 tracks MPEG-TS/HLS-Direct session tracking.

- [ ] **Step 5: Push and close**

After the `done` skill workflow approves closeout, push commits and close #97 with a structured comment.
