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; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public class ScheduleController(IMediator mediator) : ControllerBase { [HttpGet("/api/v1/schedules")] [Tags("Schedules")] [EndpointSummary("Get all schedules")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) { List schedules = await mediator.Send(new GetAllProgramSchedules(), cancellationToken); return schedules.Map(ProjectToResponseModel).ToList(); } [HttpGet("/api/v1/schedules/{id:int}", Name = "GetScheduleById")] [Tags("Schedules")] [EndpointSummary("Get a schedule by id")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetById(int id, CancellationToken cancellationToken) { Option result = await mediator.Send(new GetProgramScheduleById(id), cancellationToken); return result.Map(ProjectToResponseModel).ToGetResult(); } [HttpPost("/api/v1/schedules")] [Tags("Schedules")] [EndpointSummary("Create a schedule")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Create( [Required][FromBody] CreateScheduleRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCreateCommand(), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async created => { Option schedule = await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken); return schedule.Match( Some: vm => (IActionResult)new CreatedResult( $"/api/v1/schedules/{vm.Id}", ProjectToResponseModel(vm)), None: () => ApiResults.NotFoundProblem()); }); } [HttpPut("/api/v1/schedules/{id:int}")] [Tags("Schedules")] [EndpointSummary("Update a schedule")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ProgramScheduleResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Update( int id, [Required][FromBody] UpdateScheduleRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(id), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { Option schedule = await mediator.Send(new GetProgramScheduleById(id), cancellationToken); return schedule.Match( Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)), None: () => ApiResults.NotFoundProblem()); }); } [HttpDelete("/api/v1/schedules/{id:int}")] [Tags("Schedules")] [EndpointSummary("Delete a schedule")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Delete(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteProgramSchedule(id), cancellationToken); return result.ToDeletedResult(); } [HttpGet("/api/v1/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Get schedule items")] [EndpointDescription( "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " + "nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " + "derived from referenced collection/media runtimes and are null when unbounded or unknown. The " + "response also carries a strong ETag of the schedule's version; pass that ETag back as If-Match on " + "the replace (PUT) to detect a concurrent edit (issue #253).")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { Option schedule = await mediator.Send(new GetProgramScheduleById(id), cancellationToken); if (schedule.IsNone) { return ApiResults.NotFoundProblem(); } // The items GET returns children, not the root, so read the schedule's version for the ETag. ConcurrencyHeaders.SetETag(Response, schedule.Map(s => s.Version).IfNone(0)); ProgramScheduleItemsWithDurationViewModel items = await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken); return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items)); } [HttpPost("/api/v1/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Add a schedule item")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ScheduleItemResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddItem( int id, [Required][FromBody] ScheduleItemRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToAddCommand(id), cancellationToken); return result.ToCreatedResult( item => $"/api/v1/schedules/{id}/items/{item.Id}", item => ScheduleItemResponseMapper.ProjectToResponseModel(item)); } [HttpPut("/api/v1/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Replace schedule items")] [EndpointDescription( "Replaces the schedule's full item list; item indexes are assigned from the array order. Send the " + "ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful " + "response carries the new ETag.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceItems( int id, [Required][FromBody] ReplaceScheduleItemsRequest request, CancellationToken cancellationToken) { IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request); if (ifMatch.Kind is IfMatchKind.Malformed) { return new BadRequestObjectResult( new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Invalid If-Match header", Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"." }); } Either> result = await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersions), cancellationToken); return await result.Match( Left: error => Task.FromResult(error.ToErrorResult()), Right: async _ => { // Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than // the returned items (issue #253 fail-safe ordering; matches BlockController). A None root // (deleted between commit and reload) is a 404, never a 200 without an ETag. Option refreshed = await mediator.Send(new GetProgramScheduleById(id), cancellationToken); List items = await mediator.Send(new GetProgramScheduleItems(id), cancellationToken); return refreshed.Match( Some: vm => { // Return the new ETag so a same-tab second save doesn't 412 against its own write. ConcurrencyHeaders.SetETag(Response, vm.Version); return (IActionResult)new OkObjectResult( items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList()); }, None: () => ApiResults.NotFoundProblem()); }); } [HttpDelete("/api/v1/schedules/{id:int}/items/{itemId:int}")] [Tags("Schedules")] [EndpointSummary("Delete a schedule item")] [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task DeleteItem(int id, int itemId, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteProgramScheduleItem(id, itemId), cancellationToken); return result.ToDeletedResult(); } private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) => new( vm.Id, vm.Name, vm.KeepMultiPartEpisodesTogether, vm.TreatCollectionsAsShows, vm.ShuffleScheduleItems, vm.RandomStartPoint, vm.FixedStartTimeBehavior, vm.PadToNearestMinute); }