feat(api): flat schedule-item DTO + discovery endpoints (#126 #207 #212)

Task A (#126): new non-polymorphic ScheduleItemResponseModel /
ScheduleItemsResponseModel in Core/Api/Scheduling, plus shared
NamedIdResponseModel. ScheduleItemResponseMapper flattens the
One/Flood/Multiple/Duration VM hierarchy. ScheduleController GET/POST/PUT
items now return the flat DTOs.

Task B: GET /api/languages (LanguagesController + LanguageCodeResponseModel);
GET /api/channels/music-video-credits-templates and
GET /api/channels/stream-selectors; FillerKind added to
FillerPresetResponseModel with optional ?fillerKind= filter on
GET /api/filler-presets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 23:40:47 +02:00
co-authored by Claude Fable 5
parent 8b77d5e739
commit d7782dccac
15 changed files with 311 additions and 21 deletions
@@ -3,6 +3,7 @@ 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;
@@ -43,6 +44,22 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
[HttpGet("/api/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/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/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Filler;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -16,10 +17,13 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
[Tags("Filler Presets")]
[EndpointSummary("Get all filler presets")]
[EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
public async Task<List<FillerPresetResponseModel>> GetAll(
[FromQuery] FillerKind? fillerKind,
CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken);
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
[Tags("Filler Presets")]
@@ -0,0 +1,25 @@
using System.Linq;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Api.MediaItems;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class LanguagesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/languages", Name = "GetLanguages")]
[Tags("Languages")]
[EndpointSummary("Get all available language codes")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<LanguageCodeResponseModel>), StatusCodes.Status200OK)]
public async Task<List<LanguageCodeResponseModel>> GetLanguages(CancellationToken cancellationToken)
{
List<LanguageCodeViewModel> languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken);
return languages
.Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName))
.ToList();
}
}
+12 -6
View File
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Linq;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -104,7 +106,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
{
@@ -116,14 +118,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase
ProgramScheduleItemsWithDurationViewModel items =
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
return new OkObjectResult(items);
return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items));
}
[HttpPost("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Add a schedule item")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ScheduleItemResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> AddItem(
@@ -133,14 +135,16 @@ public class ScheduleController(IMediator mediator) : ControllerBase
{
Either<BaseError, ProgramScheduleItemViewModel> result =
await mediator.Send(request.ToAddCommand(id), cancellationToken);
return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item);
return result.ToCreatedResult(
item => $"/api/schedules/{id}/items/{item.Id}",
item => ScheduleItemResponseMapper.ProjectToResponseModel(item));
}
[HttpPut("/api/schedules/{id:int}/items")]
[Tags("Schedules")]
[EndpointSummary("Replace schedule items")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(IEnumerable<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(List<ScheduleItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceItems(
@@ -150,7 +154,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
{
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
return result
.Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList())
.ToUpdatedResult();
}
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]