Files
ersatztv/ErsatzTV/Controllers/Api/ChannelController.cs
T
timothy 214fad2dcd fix(633): document the 0-based paging contract on the OpenAPI parameters
`api.paging-zero-based` says `pageNum` is 0-based across `/api/v1` and every wrapper
of it. That was true of the MCP tool catalog and the docs, and not true of the
generated OpenAPI document: all 24 paging parameters across the 12 paged operations
were emitted with no `description` at all, so a consumer reading only `v1.json` — the
intended contract, and what generated clients surface to their users — had to infer
the base from `default: 0`. That is the same inference that cost #487 a verification
pass on the MCP side, where the description was present but wrong.

Annotates each `[FromQuery]` paging parameter with `[Description]`
(`System.ComponentModel`), the mechanism `parentId` already used in ImagesController,
and regenerates `v1.json`. `pageSize` states the endpoint's OWN cap, because the caps
genuinely differ — 100 typical, 200 auto-tune members, 1000 search/all-items — and the
record forbids documenting one global number; it also states that the offset derives
from the effective (capped) size, so an over-large `pageSize` narrows the page instead
of widening the offset.

The generated TypeScript client covers DTOs only, not query parameters, so it is
unchanged; `endpoint-index.md` carries summaries, not parameter descriptions, so it is
unchanged too.

Pinned by OpenApiPagingContractTests against the in-process generated document. The
test NAMES the expected set of 12 paged operations rather than only filtering for
parameters called `pageNum`: a filter cannot see an endpoint that should page and
doesn't, which is exactly how two MCP tools escaped the equivalent check in #616. Set
equality is asserted in both directions, and the caps are pinned per endpoint so a
description naming the wrong cap fails — a wrong justification outlives a wrong line.

Mutation-verified both ways: dropping one `[Description]` reddens the description test,
and making one endpoint stop exposing `pageNum`/`pageSize` under those names reddens
the set-equality test.

Refs #633

Decisions-Edit: yes
2026-07-26 11:10:22 +02:00

345 lines
17 KiB
C#

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Templates;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelController(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IMediator mediator,
IEntityLocker entityLocker)
{
[HttpGet("/api/v1/channels")]
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() =>
await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/v1/channels/state")]
[Tags("Channels")]
[EndpointSummary("Get channel runtime state")]
[EndpointGroupName("general")]
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
[HttpGet("/api/v1/guide")]
[Tags("Channels")]
[EndpointSummary("Get the JSON channel guide (EPG)")]
[EndpointDescription(
"Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, " +
"and end defaults to start plus the configured XmltvDaysToBuild window.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)]
public async Task<ChannelGuideResponseModel> GetGuide(
[FromQuery] DateTimeOffset? start,
[FromQuery] DateTimeOffset? end,
CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
[HttpGet("/api/v1/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
[Tags("Channels")]
[EndpointSummary("Get available music video credits template names")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public async Task<List<string>> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) =>
await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken);
[HttpGet("/api/v1/channels/stream-selectors", Name = "GetChannelStreamSelectors")]
[Tags("Channels")]
[EndpointSummary("Get available channel stream selector names")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
[HttpGet("/api/v1/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelDetailResponseModel> result = await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/v1/channels")]
[Tags("Channels")]
[EndpointSummary("Create a channel")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelDetailResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required][FromBody] CreateChannelRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateChannelResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<ChannelDetailResponseModel> channel =
await mediator.Send(new GetChannelByIdForApi(created.ChannelId), cancellationToken);
return channel.Match(
Some: model => (IActionResult)new CreatedResult($"/api/v1/channels/{model.Id}", model),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpPost("/api/v1/channels/from-lineup", Name = "CreateChannelFromLineup")]
[Tags("Channels")]
[EndpointSummary("Create a channel from a library lineup")]
[EndpointDescription(
"Atomically creates the channel, program schedule, a classic playout, and (for multi-item lineups) a " +
"generated system playlist. A single-item lineup produces one flood schedule item that references the " +
"target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or " +
"playlist) and no generated playlist. A lineup with two or more items produces one generated system " +
"playlist whose entries play in the given order (each entry played in full before the next) referenced by " +
"one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi " +
"collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets " +
"how items within each lineup entry are ordered. Template defaults are stamped at create time; advanced " +
"overrides win.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(CreateChannelFromLineupResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateFromLineup(
[Required][FromBody] CreateChannelFromLineupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(response => $"/api/v1/channels/{response.ChannelId}", response => response);
}
[HttpPut("/api/v1/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Update a channel")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required][FromBody] UpdateChannelRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ChannelViewModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
// Re-project through the read-side query so the response carries the same
// ChannelDetailResponseModel shape (raw editable ids the editor re-drafts) as GET.
Option<ChannelDetailResponseModel> channel =
await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
return channel.Match(
Some: model => (IActionResult)new OkObjectResult(model),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/v1/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Delete a channel")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannel(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/v1/channels/bulk/renumber")]
[Tags("Channels")]
[EndpointSummary("Renumber channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkRenumber(
[Required][FromBody] BulkRenumberChannelsRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[HttpPost("/api/v1/channels/bulk/group")]
[Tags("Channels")]
[EndpointSummary("Move channels to a group")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkMoveToGroup(
[Required][FromBody] BulkMoveChannelsToGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/v1/channels/bulk/delete")]
[Tags("Channels")]
[EndpointSummary("Delete channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkDelete(
[Required][FromBody] BulkDeleteChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/v1/channels/auto-tune/preview", Name = "PreviewAutoTuneChannels")]
[Tags("Channels")]
[EndpointSummary("Preview auto-tuned channels")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<AutoTuneProposalResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> PreviewAutoTune(
[Required][FromBody] PreviewAutoTuneChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, List<AutoTuneProposal>> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Left: error => error.ToErrorResult(),
Right: proposals => new OkObjectResult(proposals.Select(ProjectToResponseModel).ToList()));
}
[HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")]
[Tags("Channels")]
[EndpointSummary("Create auto-tuned channels")]
[EndpointDescription(
"Bulk-creates the selected auto-tuned channels. Each channel is independent — one failure never " +
"aborts the batch (see the per-channel Created/Skipped/Failed outcomes). Per channel, the optional " +
"templateId, advanced overrides (same set as the manual Channel Builder; advanced values win over " +
"the template) and an uploaded logo image override the batch defaults; an omitted field keeps the " +
"batch template, the axis-derived playback order and the on-the-fly fallback logo.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)]
public async Task<IActionResult> CreateAutoTuned(
[Required][FromBody] CreateAutoTunedChannelsRequest request,
CancellationToken cancellationToken)
{
AutoTuneResult result = await mediator.Send(request.ToCommand(), cancellationToken);
return new OkObjectResult(ProjectToResponseModel(result));
}
[HttpGet("/api/v1/channels/auto-tune/members", Name = "GetAutoTuneChannelMembers")]
[Tags("Channels")]
[EndpointSummary("List a proposed auto-tune channel's distinct content-source members")]
[EndpointDescription(
"Given an auto-tune axis and value, returns the distinct content sources (parent shows for the " +
"TV axes, movies for the movie-genre axis) the server-generated SmartCollection query resolves " +
"to, with a per-source item count. Read-only; the server owns query generation.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLibraryBrowseItemsResponseModel> GetAutoTuneChannelMembers(
[FromQuery] AutoTuneAxis axis,
[FromQuery] string value,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum,
[FromQuery]
[Description("Rows per page; capped at 200 for this endpoint. A value of 0 or less falls back to 100 rather than being clamped to 1. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize,
CancellationToken cancellationToken)
{
pageNum = Math.Max(0, pageNum);
pageSize = pageSize <= 0 ? 100 : Math.Min(pageSize, 200);
return await mediator.Send(
new GetAutoTuneChannelMembers(axis, value, pageNum, pageSize),
cancellationToken);
}
private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) =>
new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists);
private static AutoTuneResultResponseModel ProjectToResponseModel(AutoTuneResult r) =>
new(
r.Results.Select(o => new AutoTuneChannelResultModel(
o.Name, o.Status.ToString(), o.ChannelId, o.Reason)).ToList(),
r.CreatedCount,
r.SkippedCount,
r.FailedCount);
[HttpPost("/api/v1/channels/{id:int}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
[EndpointDescription(
"When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " +
"progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
"Pass mode to force a specific PlayoutBuildMode.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout(
int id,
[FromQuery] PlayoutBuildMode? mode,
CancellationToken cancellationToken)
{
Option<int> maybePlayoutId =
await mediator.Send(new GetPlayoutIdByChannelId(id), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
if (entityLocker.IsPlayoutLocked(playoutId))
{
return ApiResults.ConflictProblem(
"Playout build in progress",
"A build for this playout is currently in progress; try again once it completes.");
}
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new AcceptedResult();
}
return ApiResults.NotFoundProblem();
}
// Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress),
// every other kind resets from scratch.
private async Task<PlayoutBuildMode> DefaultResetMode(int playoutId, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout =
await mediator.Send(new GetPlayoutById(playoutId), cancellationToken);
return maybePlayout.Match(
Some: vm => vm.ScheduleKind switch
{
PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh,
_ => PlayoutBuildMode.Reset
},
None: () => PlayoutBuildMode.Reset);
}
}