From b53b06c61519ffc9d47ad59732ee9a320d066521 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 29 Jun 2026 20:31:18 +0200 Subject: [PATCH] feat(api): add playout REST endpoints fixes #37 --- .../Commands/CreateClassicPlayoutHandler.cs | 42 ++- .../Playouts/Commands/DeletePlayoutHandler.cs | 3 +- .../Api/Playouts/PlayoutResponseModel.cs | 33 ++ .../Playouts/PlayoutHandlerTests.cs | 104 ++++++ .../ApiErrorResponseMetadataTests.cs | 5 + .../OpenApiErrorResponseContractTests.cs | 5 + .../Controllers/PlayoutControllerTests.cs | 208 ++++++++++++ .../PlayoutLifecycleIntegrationTests.cs | 130 ++++++++ ErsatzTV/Controllers/Api/PlayoutController.cs | 77 +++++ .../Api/Requests/CreatePlayoutRequest.cs | 8 + ErsatzTV/wwwroot/openapi/v1.json | 306 ++++++++++++++++++ 11 files changed, 905 insertions(+), 16 deletions(-) create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs create mode 100644 ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs create mode 100644 ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs create mode 100644 ErsatzTV/Controllers/Api/PlayoutController.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs diff --git a/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs index 8606b26a9..ca7a0ea5f 100644 --- a/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/CreateClassicPlayoutHandler.cs @@ -2,6 +2,7 @@ using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -28,8 +29,23 @@ public class CreateClassicPlayoutHandler : IRequestHandler validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(playout => PersistPlayout(dbContext, playout)); + Option maybeChannel = await GetChannel(dbContext, request, cancellationToken); + return await maybeChannel.Match( + Some: async channel => + { + Option maybeProgramSchedule = + await GetProgramSchedule(dbContext, request, cancellationToken); + return await maybeProgramSchedule.Match( + Some: async programSchedule => + { + Validation validation = Validate(request, channel, programSchedule); + return await validation.Apply(playout => PersistPlayout(dbContext, playout)); + }, + None: () => Task.FromResult>( + new NotFoundError("Program schedule does not exist"))); + }, + None: () => Task.FromResult>( + new NotFoundError("Channel does not exist"))); } private async Task PersistPlayout(TvContext dbContext, Playout playout) @@ -46,12 +62,12 @@ public class CreateClassicPlayoutHandler : IRequestHandler> Validate( - TvContext dbContext, + private static Validation Validate( CreateClassicPlayout request, - CancellationToken cancellationToken) => - (await ValidateChannel(dbContext, request, cancellationToken), - await ValidateProgramSchedule(dbContext, request, cancellationToken), + Channel channel, + ProgramSchedule programSchedule) => + (ChannelMustNotHavePlayouts(channel), + ProgramScheduleMustHaveItems(programSchedule), ValidateScheduleKind(request)) .Apply((channel, programSchedule, scheduleKind) => new Playout { @@ -60,15 +76,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler> ValidateChannel( + private static Task> GetChannel( TvContext dbContext, CreateClassicPlayout createClassicPlayout, CancellationToken cancellationToken) => dbContext.Channels .Include(c => c.Playouts) - .SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken) - .Map(o => o.ToValidation("Channel does not exist")) - .BindT(ChannelMustNotHavePlayouts); + .SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken); private static Validation ChannelMustNotHavePlayouts(Channel channel) => Optional(channel.Playouts.Count) @@ -76,15 +90,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler channel) .ToValidation("Channel already has one playout"); - private static Task> ValidateProgramSchedule( + private static Task> GetProgramSchedule( TvContext dbContext, CreateClassicPlayout createClassicPlayout, CancellationToken cancellationToken) => dbContext.ProgramSchedules .Include(ps => ps.Items) - .SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken) - .Map(o => o.ToValidation("Program schedule does not exist")) - .BindT(ProgramScheduleMustHaveItems); + .SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken); private static Validation ProgramScheduleMustHaveItems( ProgramSchedule programSchedule) => diff --git a/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs index b47193413..c6434eaff 100644 --- a/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Notifications; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -45,6 +46,6 @@ public class DeletePlayoutHandler( return maybePlayout .Map(_ => Unit.Default) - .ToEither(BaseError.New($"Playout {request.PlayoutId} does not exist.")); + .ToEither(new NotFoundError($"Playout {request.PlayoutId} does not exist.")); } } diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs new file mode 100644 index 000000000..a80fd6ed0 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutResponseModel.cs @@ -0,0 +1,33 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutResponseModel( + int Id, + PlayoutScheduleKind ScheduleKind, + string ChannelName, + string ChannelNumber, + ChannelPlayoutMode PlayoutMode, + string ScheduleName, + string ScheduleFile, + TimeSpan? DailyRebuildTime) +{ + public static PlayoutResponseModel From( + int id, + PlayoutScheduleKind scheduleKind, + string channelName, + string channelNumber, + ChannelPlayoutMode playoutMode, + string scheduleName, + string scheduleFile, + TimeSpan? dailyRebuildTime) => + new( + id, + scheduleKind, + channelName, + channelNumber, + playoutMode, + scheduleName, + scheduleFile, + dailyRebuildTime); +} diff --git a/ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs b/ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs new file mode 100644 index 000000000..5377658e5 --- /dev/null +++ b/ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs @@ -0,0 +1,104 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; +using Unit = LanguageExt.Unit; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Playouts; + +[TestFixture] +public class PlayoutHandlerTests +{ + private InMemoryTvContext _db = null!; + private ChannelWriter _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded().Writer; + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task CreateClassic_Should_Return_NotFoundError_When_Channel_Missing() + { + var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory); + + Either result = + await handler.Handle(new CreateClassicPlayout(999, 1), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task CreateClassic_Should_Return_NotFoundError_When_Schedule_Missing() + { + int channelId = await SeedChannel(); + var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory); + + Either result = + await handler.Handle(new CreateClassicPlayout(channelId, 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_NotFoundError_When_Playout_Missing() + { + var handler = new DeletePlayoutHandler( + _worker, + _db.Factory, + new MockFileSystem(), + Substitute.For()); + + Either result = await handler.Handle(new DeletePlayout(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); + + private async Task SeedChannel() + { + await using TvContext context = _db.CreateContext(); + var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid()) + { + Number = "101", + SortNumber = 101, + Name = "Handler", + Group = string.Empty, + Categories = string.Empty, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + Playouts = [], + Artwork = [], + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + IsEnabled = true, + ShowInEpg = true + }; + context.Channels.Add(channel); + await context.SaveChangesAsync(); + return channel.Id; + } +} diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 252e8ace8..14f15bf18 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -50,6 +50,11 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status404NotFound)] [TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)] + [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)] public void Api_Error_Response_Metadata_Should_Document_ProblemDetails( Type controllerType, string actionName, diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 856184fd4..83f378b0e 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -63,6 +63,11 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/schedules/{id}/items", "put", "422")] [TestCase("/api/schedules/{id}/items/{itemId}", "delete", "404")] [TestCase("/api/schedules/{id}/items/{itemId}", "delete", "422")] + [TestCase("/api/playouts/{id}", "get", "404")] + [TestCase("/api/playouts", "post", "404")] + [TestCase("/api/playouts", "post", "422")] + [TestCase("/api/playouts/{id}", "delete", "404")] + [TestCase("/api/playouts/{id}", "delete", "422")] public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( string path, string method, diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs new file mode 100644 index 000000000..7125d03dd --- /dev/null +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -0,0 +1,208 @@ +using System.Reflection; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Filters; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class PlayoutControllerTests +{ + private PlayoutController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new PlayoutController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}"); + ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts"); + ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}"); + } + + [Test] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter() + { + ServiceFilterAttribute? filter = typeof(PlayoutController) + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull("PlayoutController must carry ApiKeyAuthorizationFilter at the class level"); + } + + [Test] + public void Create_Should_Use_Stable_Request_Dto() + { + MethodInfo action = typeof(PlayoutController).GetMethod(nameof(PlayoutController.Create)) + ?? throw new AssertionException($"Missing action {nameof(PlayoutController.Create)}"); + + action.GetParameters()[0].ParameterType.ShouldBe(typeof(CreatePlayoutRequest)); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreatePlayoutResponse(9))); + PlayoutNameViewModel vm = MakePlayout(9); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/playouts/9"); + created.Value.ShouldBe(ToResponse(vm)); + } + + [Test] + public async Task Create_Should_Map_Request_To_Classic_Playout_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreatePlayoutResponse(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.ChannelId == 3 && c.ProgramScheduleId == 4), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_404_For_NotFoundError_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Create(new CreatePlayoutRequest(404, 4), CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create(new CreatePlayoutRequest(3, 4), CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + problem.Title.ShouldBe("Validation failed"); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9), + Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(404, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + PlayoutNameViewModel vm = MakePlayout(9); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(9, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(ToResponse(vm)); + } + + [Test] + public async Task GetById_Should_Return_404_For_None_With_ProblemDetails() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(9, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problem = notFound.Value.ShouldBeOfType(); + problem.Status.ShouldBe(404); + problem.Title.ShouldBe("Resource not found"); + } + + private static PlayoutNameViewModel MakePlayout(int id) => + new( + id, + PlayoutScheduleKind.Classic, + "Channel", + "101", + ChannelPlayoutMode.Continuous, + "Schedule", + string.Empty, + null, + new PlayoutBuildStatus()); + + private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => + PlayoutResponseModel.From( + vm.PlayoutId, + vm.ScheduleKind, + vm.ChannelName, + vm.ChannelNumber, + vm.PlayoutMode, + vm.ScheduleName, + vm.ScheduleFile, + vm.DbDailyRebuildTime); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(PlayoutController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs b/ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs new file mode 100644 index 000000000..aa846a305 --- /dev/null +++ b/ErsatzTV.Tests/Integration/PlayoutLifecycleIntegrationTests.cs @@ -0,0 +1,130 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using MediatR; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; +using Unit = LanguageExt.Unit; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Integration; + +[TestFixture] +public class PlayoutLifecycleIntegrationTests +{ + private InMemoryTvContext _db = null!; + private Channel _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task CreateClassic_Should_Link_Channel_And_Schedule_And_Enqueue_Build() + { + (int channelId, int scheduleId) = await SeedChannelAndSchedule(); + var handler = new CreateClassicPlayoutHandler(_worker.Writer, _db.Factory); + + Either result = + await handler.Handle(new CreateClassicPlayout(channelId, scheduleId), CancellationToken.None); + + CreatePlayoutResponse response = result.Match( + Right: r => r, + Left: error => throw new AssertionException(error.Value)); + + await using TvContext context = _db.CreateContext(); + Playout playout = await context.Playouts.SingleAsync(); + playout.Id.ShouldBe(response.PlayoutId); + playout.ChannelId.ShouldBe(channelId); + playout.ProgramScheduleId.ShouldBe(scheduleId); + playout.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic); + + IBackgroundServiceRequest backgroundRequest = await _worker.Reader.ReadAsync(); + backgroundRequest.ShouldBeOfType().PlayoutId.ShouldBe(playout.Id); + } + + [Test] + public async Task Delete_Should_Remove_Playout_Without_Deleting_Channel_Or_Schedule() + { + (int channelId, int scheduleId) = await SeedChannelAndSchedule(); + var createHandler = new CreateClassicPlayoutHandler(_worker.Writer, _db.Factory); + int playoutId = (await createHandler.Handle(new CreateClassicPlayout(channelId, scheduleId), CancellationToken.None)) + .Match(Right: r => r.PlayoutId, Left: error => throw new AssertionException(error.Value)); + var deleteHandler = new DeletePlayoutHandler( + _worker.Writer, + _db.Factory, + new MockFileSystem(), + Substitute.For()); + + Either result = await deleteHandler.Handle(new DeletePlayout(playoutId), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await using TvContext context = _db.CreateContext(); + (await context.Playouts.CountAsync()).ShouldBe(0); + (await context.Channels.CountAsync(c => c.Id == channelId)).ShouldBe(1); + (await context.ProgramSchedules.CountAsync(ps => ps.Id == scheduleId)).ShouldBe(1); + } + + private async Task<(int ChannelId, int ScheduleId)> SeedChannelAndSchedule() + { + await using TvContext context = _db.CreateContext(); + var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid()) + { + Number = "101", + SortNumber = 101, + Name = "Lifecycle", + Group = string.Empty, + Categories = string.Empty, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + Playouts = [], + Artwork = [], + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + IsEnabled = true, + ShowInEpg = true + }; + var schedule = new ProgramSchedule + { + Name = "Lifecycle", + Items = + [ + new ProgramScheduleItemOne + { + Index = 0, + CollectionType = CollectionType.SearchQuery, + SearchQuery = "news", + PlaybackOrder = PlaybackOrder.Shuffle, + GuideMode = GuideMode.Normal, + Watermarks = [], + GraphicsElements = [] + } + ], + Playouts = [], + ProgramScheduleAlternates = [] + }; + context.Channels.Add(channel); + context.ProgramSchedules.Add(schedule); + await context.SaveChangesAsync(); + return (channel.Id, schedule.Id); + } +} diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs new file mode 100644 index 000000000..366257cd0 --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -0,0 +1,77 @@ +using System.ComponentModel.DataAnnotations; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Playouts; +using ErsatzTV.Extensions; +using ErsatzTV.Filters; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] +public class PlayoutController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")] + [Tags("Playouts")] + [EndpointSummary("Get a playout by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetPlayoutById(id), cancellationToken); + return result.Map(ToResponse).ToGetResult(); + } + + [HttpPost("/api/playouts")] + [Tags("Playouts")] + [EndpointSummary("Create a classic playout")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreatePlayoutRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async created => + { + Option playout = + await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken); + return playout.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/playouts/{vm.PlayoutId}", ToResponse(vm)), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpDelete("/api/playouts/{id:int}")] + [Tags("Playouts")] + [EndpointSummary("Delete a playout")] + [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 DeletePlayout(id), cancellationToken); + return result.ToDeletedResult(); + } + + private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => + PlayoutResponseModel.From( + vm.PlayoutId, + vm.ScheduleKind, + vm.ChannelName, + vm.ChannelNumber, + vm.PlayoutMode, + vm.ScheduleName, + vm.ScheduleFile, + vm.DbDailyRebuildTime); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs new file mode 100644 index 000000000..d82dcf03a --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreatePlayoutRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Playouts; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreatePlayoutRequest(int ChannelId, int ProgramScheduleId) +{ + public CreateClassicPlayout ToCommand() => new(ChannelId, ProgramScheduleId); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 4da969587..ba1907e60 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -1190,6 +1190,225 @@ } } }, + "/api/playouts/{id}": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Get a playout by id", + "operationId": "GetPlayoutById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Playouts" + ], + "summary": "Delete a playout", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/playouts": { + "post": { + "tags": [ + "Playouts" + ], + "summary": "Create a classic playout", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreatePlayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/ffmpeg/resolution/by-name/{name}": { "get": { "tags": [ @@ -3235,6 +3454,23 @@ } } }, + "CreatePlayoutRequest": { + "required": [ + "channelId", + "programScheduleId" + ], + "type": "object", + "properties": { + "channelId": { + "type": "integer", + "format": "int32" + }, + "programScheduleId": { + "type": "integer", + "format": "int32" + } + } + }, "CreateScheduleRequest": { "required": [ "name", @@ -3956,6 +4192,73 @@ ], "type": "string" }, + "PlayoutResponseModel": { + "required": [ + "id", + "scheduleKind", + "channelName", + "channelNumber", + "playoutMode", + "scheduleName", + "scheduleFile", + "dailyRebuildTime" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "scheduleKind": { + "$ref": "#/components/schemas/PlayoutScheduleKind" + }, + "channelName": { + "type": [ + "null", + "string" + ] + }, + "channelNumber": { + "type": [ + "null", + "string" + ] + }, + "playoutMode": { + "$ref": "#/components/schemas/ChannelPlayoutMode" + }, + "scheduleName": { + "type": [ + "null", + "string" + ] + }, + "scheduleFile": { + "type": [ + "null", + "string" + ] + }, + "dailyRebuildTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, + "PlayoutScheduleKind": { + "enum": [ + "None", + "Classic", + "Block", + "Sequential", + "Scripted", + "ExternalJson" + ], + "type": "string" + }, "ProblemDetails": { "type": "object", "properties": { @@ -5186,6 +5489,9 @@ { "name": "Maintenance" }, + { + "name": "Playouts" + }, { "name": "Resolution" },