feat(api): #288/#197 wrap ChannelViewModel + re-key playout/reset to {id}
GetById/Create/Update return ChannelResponseModel via new GetChannelByIdForApi
read-side query; POST /api/channels/{id:int}/playout/reset (new
GetPlayoutIdByChannelId; by-number kept for HlsSessionWorker broadcast).
Refs #288 #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelByIdForApi(int Id) : IRequest<Option<ChannelResponseModel>>;
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelByIdForApi, Option<ChannelResponseModel>>
|
||||
{
|
||||
public Task<Option<ChannelResponseModel>> Handle(GetChannelByIdForApi request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(ProjectToResponseModel);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutIdByChannelId(int ChannelId) : IRequest<Option<int>>;
|
||||
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutIdByChannelIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlayoutIdByChannelId, Option<int>>
|
||||
{
|
||||
public async Task<Option<int>> Handle(GetPlayoutIdByChannelId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Filter(p => p.Channel.Id == request.ChannelId)
|
||||
.Map(p => p.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.HeadOrNone());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.OrderBy(c => c.Id)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelByIdForApiHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Project_ResponseModel_When_Found()
|
||||
{
|
||||
IChannelRepository repository = Substitute.For<IChannelRepository>();
|
||||
repository.GetChannel(7)
|
||||
.Returns(Option<Channel>.Some(new Channel(Guid.NewGuid())
|
||||
{
|
||||
Id = 7,
|
||||
Number = "7.1",
|
||||
SortNumber = 7.1,
|
||||
Name = "Retro Cartoons",
|
||||
Group = "Kids",
|
||||
Categories = "animation",
|
||||
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
|
||||
PreferredAudioLanguageCode = "eng",
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
|
||||
IsEnabled = false,
|
||||
ShowInEpg = false
|
||||
}));
|
||||
var handler = new GetChannelByIdForApiHandler(repository);
|
||||
|
||||
Option<ChannelResponseModel> result =
|
||||
await handler.Handle(new GetChannelByIdForApi(7), CancellationToken.None);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
ChannelResponseModel channel = result.IfNone(() => throw new InvalidOperationException());
|
||||
channel.Id.ShouldBe(7);
|
||||
channel.Number.ShouldBe("7.1");
|
||||
channel.SortNumber.ShouldBe(7.1);
|
||||
channel.Name.ShouldBe("Retro Cartoons");
|
||||
channel.FFmpegProfile.ShouldBe("HLS 720p");
|
||||
channel.Language.ShouldBe("eng");
|
||||
channel.StreamingMode.ShouldBe("HLS Segmenter");
|
||||
channel.IsEnabled.ShouldBeFalse();
|
||||
channel.ShowInEpg.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_None_When_Missing()
|
||||
{
|
||||
IChannelRepository repository = Substitute.For<IChannelRepository>();
|
||||
repository.GetChannel(Arg.Any<int>()).Returns(Option<Channel>.None);
|
||||
var handler = new GetChannelByIdForApiHandler(repository);
|
||||
|
||||
Option<ChannelResponseModel> result =
|
||||
await handler.Handle(new GetChannelByIdForApi(99), CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Playouts;
|
||||
|
||||
[TestFixture]
|
||||
public class GetPlayoutIdByChannelIdHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private GetPlayoutIdByChannelIdHandler CreateHandler() => new(_db.Factory);
|
||||
|
||||
private async Task<(int ChannelId, int PlayoutId)> SeedChannelWithPlayout(string number)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var channel = new Channel(Guid.NewGuid()) { Number = number, Name = $"Channel {number}" };
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var playout = new Playout { ChannelId = channel.Id, ScheduleKind = PlayoutScheduleKind.Classic };
|
||||
context.Playouts.Add(playout);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return (channel.Id, playout.Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Playout_Id_For_Channel_Id()
|
||||
{
|
||||
(int channelId, int playoutId) = await SeedChannelWithPlayout("5.1");
|
||||
|
||||
Option<int> result = await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId), CancellationToken.None);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(-1).ShouldBe(playoutId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_None_When_Channel_Has_No_Playout()
|
||||
{
|
||||
(int channelId, _) = await SeedChannelWithPlayout("5.1");
|
||||
|
||||
Option<int> result =
|
||||
await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId + 1000), CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -97,20 +97,20 @@ public class ChannelControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
public async Task Create_Should_Return_201_With_Location_And_ResponseModel_Body()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
|
||||
ChannelViewModel vm = MakeVm(5);
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.Some(vm));
|
||||
ChannelResponseModel model = MakeResponseModel(5);
|
||||
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelResponseModel>.Some(model));
|
||||
|
||||
IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/channels/5");
|
||||
created.Value.ShouldBe(vm);
|
||||
created.Value.ShouldBe(model);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -118,8 +118,8 @@ public class ChannelControllerTests
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.Some(MakeVm(5)));
|
||||
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelResponseModel>.Some(MakeResponseModel(5)));
|
||||
|
||||
await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
|
||||
|
||||
@@ -188,18 +188,23 @@ public class ChannelControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_And_Map_Route_Id()
|
||||
public async Task Update_Should_Return_200_With_ResponseModel_And_Map_Route_Id()
|
||||
{
|
||||
ChannelViewModel vm = MakeVm(7);
|
||||
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, ChannelViewModel>(vm));
|
||||
.Returns(Right<BaseError, ChannelViewModel>(MakeVm(7)));
|
||||
ChannelResponseModel model = MakeResponseModel(7);
|
||||
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelResponseModel>.Some(model));
|
||||
|
||||
IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateChannel>(c => c.ChannelId == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetChannelByIdForApi>(q => q.Id == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -354,22 +359,22 @@ public class ChannelControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
public async Task GetById_Should_Return_200_With_ResponseModel_For_Some()
|
||||
{
|
||||
ChannelViewModel vm = MakeVm(4);
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.Some(vm));
|
||||
ChannelResponseModel model = MakeResponseModel(4);
|
||||
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelResponseModel>.Some(model));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.None);
|
||||
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelResponseModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
@@ -379,13 +384,21 @@ public class ChannelControllerTests
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetPlayout_Route_Is_Keyed_By_Int_Id()
|
||||
{
|
||||
MethodInfo reset = typeof(ChannelController).GetMethod(nameof(ChannelController.ResetPlayout))!;
|
||||
reset.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/{id:int}/playout/reset");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Return_ProblemDetails_404_For_Missing_Channel()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.None);
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
|
||||
IActionResult result = await _controller.ResetPlayout(404, mode: null, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
@@ -393,6 +406,22 @@ public class ChannelControllerTests
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Resolve_Playout_By_Channel_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, PlayoutScheduleKind.Classic)));
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout(42, mode: null, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPlayoutIdByChannelId>(q => q.ChannelId == 42),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
|
||||
[TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
|
||||
@@ -400,12 +429,12 @@ public class ChannelControllerTests
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
PlayoutBuildMode expectedMode)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
||||
IActionResult result = await _controller.ResetPlayout(5, mode: null, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
@@ -417,11 +446,11 @@ public class ChannelControllerTests
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
||||
IActionResult result = await _controller.ResetPlayout(5, mode: null, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
@@ -431,10 +460,10 @@ public class ChannelControllerTests
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
|
||||
IActionResult result = await _controller.ResetPlayout(5, PlayoutBuildMode.Continue, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
@@ -457,6 +486,20 @@ public class ChannelControllerTests
|
||||
null,
|
||||
0);
|
||||
|
||||
private static ChannelResponseModel MakeResponseModel(int id) =>
|
||||
new(
|
||||
id,
|
||||
"5",
|
||||
5.0,
|
||||
"Test",
|
||||
"ErsatzTV",
|
||||
string.Empty,
|
||||
"HLS 720p",
|
||||
"eng",
|
||||
"MPEG-TS",
|
||||
true,
|
||||
false);
|
||||
|
||||
private static ChannelViewModel MakeVm(int id) =>
|
||||
new(
|
||||
id,
|
||||
|
||||
@@ -68,11 +68,11 @@ public class ChannelController(
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ChannelResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
|
||||
Option<ChannelResponseModel> result = await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ public class ChannelController(
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Create a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ChannelResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
@@ -92,10 +92,10 @@ public class ChannelController(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async created =>
|
||||
{
|
||||
Option<ChannelViewModel> channel =
|
||||
await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
|
||||
Option<ChannelResponseModel> channel =
|
||||
await mediator.Send(new GetChannelByIdForApi(created.ChannelId), cancellationToken);
|
||||
return channel.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
|
||||
Some: model => (IActionResult)new CreatedResult($"/api/channels/{model.Id}", model),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
@@ -130,7 +130,7 @@ public class ChannelController(
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Update a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ChannelResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
@@ -140,7 +140,18 @@ public class ChannelController(
|
||||
{
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToUpdatedResult();
|
||||
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
|
||||
// ChannelResponseModel shape (incl. SortNumber and FFmpegProfile name) as GET.
|
||||
Option<ChannelResponseModel> channel =
|
||||
await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
|
||||
return channel.Match(
|
||||
Some: model => (IActionResult)new OkObjectResult(model),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/channels/{id:int}")]
|
||||
@@ -203,7 +214,7 @@ public class ChannelController(
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[HttpPost("/api/channels/{id:int}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
[EndpointDescription(
|
||||
@@ -215,12 +226,12 @@ public class ChannelController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
string channelNumber,
|
||||
int id,
|
||||
[FromQuery] PlayoutBuildMode? mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> maybePlayoutId =
|
||||
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
||||
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.
|
||||
|
||||
@@ -998,6 +998,7 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
confirm: true,
|
||||
playoutItems: [playoutItem()],
|
||||
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
|
||||
channelStates: [{ channelId: 7, channelNumber: '5.1', onAir: true, nowPlaying: null }],
|
||||
playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], totalCount: 1 }
|
||||
});
|
||||
|
||||
@@ -1009,8 +1010,9 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Keyed on the resolved channel id (7), not the playout id (20) or the channel number.
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
'/api/channels/5.1/playout/reset',
|
||||
'/api/channels/7/playout/reset',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
+10
-1
@@ -1829,8 +1829,17 @@ function PlayoutsScreen() {
|
||||
if (!selectedSummary) {
|
||||
return;
|
||||
}
|
||||
// The reset endpoint keys on the immutable channel id. The playout summary only carries the
|
||||
// channel number, so resolve the id from channel state (which covers every channel).
|
||||
const channelId = channelStates.find(
|
||||
(state) => state.channelNumber === selectedSummary.channelNumber
|
||||
)?.channelId;
|
||||
if (channelId == null) {
|
||||
setMutationError('Unable to resolve the channel for this playout.');
|
||||
return;
|
||||
}
|
||||
runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
|
||||
resetChannelPlayout(selectedSummary.channelNumber)
|
||||
resetChannelPlayout(channelId)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -115,25 +115,16 @@ describe('playouts api client', () => {
|
||||
expect(init).toMatchObject({ method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => {
|
||||
it('resetChannelPlayout POSTs to the id-keyed channel playout reset route without a mode', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
|
||||
|
||||
await resetChannelPlayout('12.3');
|
||||
await resetChannelPlayout(20);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/12.3/playout/reset');
|
||||
expect(url).toBe('/api/channels/20/playout/reset');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
});
|
||||
|
||||
it('resetChannelPlayout url-encodes the channel number', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
|
||||
|
||||
await resetChannelPlayout('a b');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/a%20b/playout/reset');
|
||||
});
|
||||
|
||||
it('erasePlayoutItems POSTs to the erase-items route', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
|
||||
|
||||
|
||||
@@ -123,8 +123,9 @@ export function deletePlayout(playoutId: number): Promise<void> {
|
||||
|
||||
// Resets the given channel's playout. The server picks the correct default mode per schedule kind
|
||||
// (Classic → Refresh, others → Reset), matching the Blazor "Reset Playout" action — so no mode is sent.
|
||||
export function resetChannelPlayout(channelNumber: string): Promise<void> {
|
||||
return request<void>(`/api/channels/${encodeURIComponent(channelNumber)}/playout/reset`, { method: 'POST' });
|
||||
// Keyed on the immutable channel id (not the user-mutable number), matching the /api/channels/{id} contract.
|
||||
export function resetChannelPlayout(channelId: number): Promise<void> {
|
||||
return request<void>(`/api/channels/${channelId}/playout/reset`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function erasePlayoutItems(playoutId: number): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user