# Channel State API Design

**Date:** 2026-07-02
**Issue:** [ersatztv#97 - Backend: channel on-air / now-playing state API](http://192.168.1.95:3000/timothy/ersatztv/issues/97)
**Status:** Implemented by #97; universal streaming-mode coverage completed by #99.

## Context

The ChicoryTV Channels prototype needs runtime channel state for the **On-air** filter, live row treatment, and **Now playing** lane. The existing `GET /api/v1/channels` endpoint returns static channel configuration and should stay cheap. Runtime state changes with wall-clock time and active streaming sessions, so it belongs in a separate pollable endpoint.

ErsatzTV has two distinct concepts:

- The playout schedule is clock-based and exists independent of viewers.
- A stream session exists only while a client is actively pulling a stream.

The API should expose both concepts separately rather than blending them into one ambiguous status.

## API Contract

Add a batched endpoint:

```http
GET /api/v1/channels/state
```

Response:

```json
[
  {
    "channelId": 7,
    "channelNumber": "7.1",
    "onAir": true,
    "nowPlaying": {
      "title": "Retro Cartoons - e01 - The Launch",
      "startUtc": "2026-07-02T17:00:00Z",
      "finishUtc": "2026-07-02T17:30:00Z"
    }
  },
  {
    "channelId": 8,
    "channelNumber": "8",
    "onAir": false,
    "nowPlaying": null
  }
]
```

Fields:

- `channelId`: channel database id for joining with `GET /api/v1/channels` rows.
- `channelNumber`: stable display/routing number, also useful for active session checks.
- `onAir`: `true` when the channel has an active segmenter-backed or direct stream session.
- `nowPlaying`: current scheduled `PlayoutItem` by wall-clock, or `null`.
- `nowPlaying.title`: display title from the existing playout title mapper.
- `nowPlaying.startUtc` / `finishUtc`: UTC item bounds. The SPA computes progress percentage client-side from these values.

The endpoint returns one row per channel so the SPA can poll the whole grid with one request. It must not require per-channel calls.

## Semantics

`nowPlaying` means "what is scheduled right now." It is populated for any channel with a built playout item covering the current wall-clock time, even when `onAir` is false.

`onAir` means "has an active stream session." It combines
`IFFmpegSegmenterService.IsActive(channelNumber)` for segmenter-backed modes with
`IDirectStreamSessionTracker.IsActive(channelNumber)` for direct modes.

Known `onAir` coverage:

| Streaming mode | Activity source |
| --- | --- |
| `HttpLiveStreamingSegmenter` | `IFFmpegSegmenterService` |
| `TransportStreamHybrid` | `IFFmpegSegmenterService` |
| `TransportStream` / MPEG-TS direct | `IDirectStreamSessionTracker` |
| `HttpLiveStreamingDirect` | `IDirectStreamSessionTracker` |

The direct-stream tracking seam landed in #99 without changing this endpoint's response contract. Direct
registrations are created only while the response body is executing and are removed on completion,
disconnect, or error; HEAD probes do not count as viewers.

## Null And Degraded States

`nowPlaying` is `null` when:

- the channel has no built playout,
- the current wall-clock time falls into a schedule gap,
- the current playout item cannot be resolved.

When no runtime data is available, the response should degrade to `onAir: false` and `nowPlaying: null`. The SPA can render that as Idle / Off air.

`nowPlaying` is also `null` when the current playout item is filler with no program item in its guide group (for example a channel looping fallback filler). When the current item is filler *inside* a program's guide group (pre/mid-roll), the endpoint surfaces the program — matching the XMLTV guide — not the filler.

Known imprecision: OnDemand channels report `nowPlaying` from stored playout items, which are only re-anchored when a viewer tunes in. While such a channel sits idle, the reported item and progress drift from what a new viewer will actually see. This is inherent to the stored data and accepted.

## Implementation Shape

Add a new MediatR query in `ErsatzTV.Application.Channels`, for example `GetChannelStatesForApi`, returning `List<ChannelStateResponseModel>`.

The handler should:

1. Load the channel ids and numbers needed for every channel.
2. Query current playout items in one batched database query using the current UTC time.
3. Include enough media metadata to map the item title with the existing `ErsatzTV.Application.Playouts.Mapper.GetDisplayTitle` behavior.
4. Join the current items back to channels in memory.
5. Ask both `IFFmpegSegmenterService.IsActive(channel.Number)` and
   `IDirectStreamSessionTracker.IsActive(channel.Number)` for each channel's `onAir` value.

Avoid an N+1 database query. The endpoint is intended for frequent table polling.

No database migration is required because the endpoint reads existing channel, playout, playout item, and media metadata state.

## Forward Compatibility

If #68 introduces "always playing" channels by holding a segmenter-backed session, `onAir` will continue to work without an API contract change. If that feature uses a different runtime mechanism, revisit the `onAir` implementation behind the same response field.

#99 added active-session tracking for MPEG-TS and HLS-Direct paths, making `onAir` universal without changing
the endpoint contract.

## Testing

Controller tests should verify:

- `GET /api/v1/channels/state` routes through MediatR and returns `200 OK`.
- The endpoint advertises a `List<ChannelStateResponseModel>` response.

Application handler tests should verify:

- A channel with an active segmenter session returns `onAir: true`.
- A channel with an active direct stream session returns `onAir: true`.
- A channel with neither kind of active session returns `onAir: false`.
- A current playout item returns `nowPlaying` with title, `startUtc`, and `finishUtc`.
- A channel without a current item returns `nowPlaying: null`.
- Multiple channels are resolved through one handler call without per-channel playout lookups.

OpenAPI contract tests should include the new route if the existing suite requires explicit route/status coverage.
