feat(api): playout read endpoints + reset-all (#100 #101 #107 #110)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

- GET /api/playouts — paged list over GetPagedPlayouts (query/pageNum/pageSize), list DTO with buildStatus (#100, #107)
- GET /api/playouts/{id}/items — paged future items + UNSCHEDULED gaps over GetFuturePlayoutItemsById; 404 ProblemDetails for unknown playout (matches /api/schedules/{id}/items precedent) (#101)
- buildStatus {lastBuild, success, message} on GET /api/playouts/{id} (BuildStatus now included by GetPlayoutByIdHandler) (#107)
- GET /api/playouts/warnings/count — failed-build count for the warnings badge (#107)
- POST /api/playouts/reset-all — 202 Accepted, wraps ResetAllPlayouts (#110)
- POST /api/channels/{channelNumber}/playout/reset — optional ?mode= override; default branches by ScheduleKind (Classic→Refresh, others→Reset) to match Blazor semantics (#110)
- Option<FillerKind>→FillerKind? projection uses MatchUnsafe (Match throws on null-returning branch; idiom per Health/Mapper.cs)
- Tests: controller unit tests (routes, projections, 404s, reset-mode defaults), OpenAPI ProblemDetails contract entry for /api/playouts/{id}/items get 404
- Regenerated wwwroot/openapi/v1.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:28:09 +02:00
co-authored by Claude Fable 5
parent 335a8b8a77
commit 20ca71b388
13 changed files with 792 additions and 78 deletions
+28 -3
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
@@ -90,18 +91,42 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[HttpPost("/api/channels/{channelNumber}/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.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ResetPlayout(string channelNumber)
public async Task<IActionResult> ResetPlayout(
string channelNumber,
[FromQuery] PlayoutBuildMode? mode,
CancellationToken cancellationToken)
{
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
Option<int> maybePlayoutId =
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset));
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult();
}
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);
}
}
+95 -1
View File
@@ -3,6 +3,8 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -13,6 +15,32 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/playouts", Name = "GetPlayouts")]
[Tags("Playouts")]
[EndpointSummary("List playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedPlayoutsResponseModel> GetAll(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
PagedPlayoutsViewModel result =
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
return new PagedPlayoutsResponseModel(
result.TotalCount,
result.Page.Map(ToListItemResponse).ToList());
}
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
[Tags("Playouts")]
[EndpointSummary("Count playouts with a failed build")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
public async Task<int> GetWarningsCount(CancellationToken cancellationToken) =>
await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
[Tags("Playouts")]
[EndpointSummary("Get a playout by id")]
@@ -25,6 +53,34 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return result.Map(ToResponse).ToGetResult();
}
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
[Tags("Playouts")]
[EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(
int id,
[FromQuery] bool showFiller = false,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
PagedPlayoutItemsViewModel result = await mediator.Send(
new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize),
cancellationToken);
return new OkObjectResult(
new PagedPlayoutItemsResponseModel(
result.TotalCount,
result.Page.Map(ToItemResponse).ToList()));
}
[HttpPost("/api/playouts")]
[Tags("Playouts")]
[EndpointSummary("Create a classic playout")]
@@ -49,6 +105,17 @@ public class PlayoutController(IMediator mediator) : ControllerBase
});
}
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
[Tags("Playouts")]
[EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
return Accepted();
}
[HttpDelete("/api/playouts/{id:int}")]
[Tags("Playouts")]
[EndpointSummary("Delete a playout")]
@@ -71,5 +138,32 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
vm.DbDailyRebuildTime);
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus));
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
new(
vm.PlayoutId,
vm.ChannelNumber,
vm.ChannelName,
vm.ScheduleKind,
vm.ScheduleName,
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus));
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
? null
: new PlayoutBuildStatusResponseModel(
buildStatus.LastBuild,
buildStatus.Success,
buildStatus.Message);
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
new(
vm.Title,
vm.Start,
vm.Finish,
vm.Duration,
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
}