feat(api): add bulk channel operations

fixes #98
This commit is contained in:
2026-07-02 19:07:35 +02:00
parent 32c0d6a6d3
commit 6b3a698283
16 changed files with 933 additions and 3 deletions
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,62 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkDeleteChannelsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IFileSystem fileSystem,
ISearchTargets searchTargets)
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkDeleteChannels request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
dbContext.Channels.RemoveRange(channels);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
foreach (Channel channel in channels)
{
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
fileSystem.File.Delete(cacheFile);
}
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
: IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,61 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkMoveChannelsToGroupHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ISearchTargets searchTargets)
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkMoveChannelsToGroup request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
if (groupValidation.IsFail)
{
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
foreach (Channel channel in channels)
{
channel.Group = request.Group;
}
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
@@ -14,18 +16,41 @@ public class UpdateChannelNumbersHandler(
{
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
{
Option<BaseError> validationError = ValidateRequest(request);
if (validationError.IsSome)
{
return validationError;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
var channelIds = numberUpdates.Keys;
List<int> channelIds = numberUpdates.Keys.ToList();
List<Channel> channelsToUpdate = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channelsToUpdate.Count != channelIds.Count)
{
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return new NotFoundError($"Channel {missingId} does not exist.");
}
List<string> requestedNumbers = numberUpdates.Values.ToList();
bool numberConflict = await dbContext.Channels
.AnyAsync(
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
cancellationToken);
if (numberConflict)
{
return BaseError.New("Channel number must be unique");
}
// give every channel a non-conflicting number
foreach (var channel in channelsToUpdate)
{
@@ -69,4 +94,32 @@ public class UpdateChannelNumbersHandler(
return BaseError.New("Failed to update channel numbers: " + ex.Message);
}
}
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
{
if (request.Channels.Count == 0)
{
return BaseError.New("At least one channel is required");
}
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
{
return BaseError.New("Channel ids must be unique");
}
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
{
return BaseError.New("Channel number must be unique");
}
foreach (ChannelSortViewModel channel in request.Channels)
{
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
}
return Option<BaseError>.None;
}
}
@@ -0,0 +1,57 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
{
private readonly MockFileSystem _fileSystem = new();
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
[Test]
public async Task Should_Delete_All_Channels()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
int count = await context.Channels.CountAsync();
count.ShouldBe(0);
SearchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
exists.ShouldBeTrue();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,74 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
{
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
[Test]
public async Task Should_Move_All_Channels_To_Group()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
List<string> groups = await context.Channels
.OrderBy(c => c.Id)
.Select(c => c.Group)
.ToListAsync();
groups.ShouldBe(["Movies", "Movies"]);
SearchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5", group: "Original");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
group.ShouldBe("Original");
}
[Test]
public async Task Should_Reject_Empty_Group()
{
await SeedChannel(1, "5", group: "Original");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("group");
await using TvContext context = Db.CreateContext();
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
group.ShouldBe("Original");
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,111 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class UpdateChannelNumbersHandlerTests : ChannelHandlerTestBase
{
private UpdateChannelNumbersHandler MakeHandler() => new(Db.Factory, Worker);
[Test]
public async Task Should_Renumber_All_Channels()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 2, Number = "11.1" }
]),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
var channels = await context.Channels
.OrderBy(c => c.Id)
.Select(c => new { c.Number, c.SortNumber })
.ToListAsync();
channels[0].Number.ShouldBe("10");
channels[0].SortNumber.ShouldBe(10);
channels[1].Number.ShouldBe("11.1");
channels[1].SortNumber.ShouldBe(11.1);
}
[Test]
public async Task Should_Return_NotFound_And_Not_Renumber_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 99, Number = "11" }
]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
number.ShouldBe("5");
}
[Test]
public async Task Should_Reject_Duplicate_Numbers_And_Not_Renumber()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 2, Number = "10" }
]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("unique");
await using TvContext context = Db.CreateContext();
List<string> numbers = await context.Channels.OrderBy(c => c.Id).Select(c => c.Number).ToListAsync();
numbers.ShouldBe(["5", "6"]);
}
[Test]
public async Task Should_Reject_Invalid_Number_And_Not_Renumber()
{
await SeedChannel(1, "5");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers([new ChannelSortViewModel { Id = 1, Number = "10.123" }]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Invalid channel number");
await using TvContext context = Db.CreateContext();
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
number.ShouldBe("5");
}
private static BaseError ErrorOf(Option<BaseError> result) =>
result.Match(
Some: error => error,
None: () => throw new AssertionException("Expected an error"));
}
@@ -17,6 +17,12 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
@@ -125,6 +125,95 @@ public class ChannelControllerTests
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task BulkRenumber_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.BulkRenumber(
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateChannelNumbers>(
c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
IActionResult result = await _controller.BulkRenumber(
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.BulkMoveToGroup(
new BulkMoveChannelsToGroupRequest([1, 2], "Movies"),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<BulkMoveChannelsToGroup>(
c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.BulkMoveToGroup(
new BulkMoveChannelsToGroupRequest([99], "Movies"),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task BulkDelete_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.BulkDelete(
new BulkDeleteChannelsRequest([1, 2]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<BulkDeleteChannels>(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkDelete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.BulkDelete(
new BulkDeleteChannelsRequest([99]),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
@@ -48,6 +48,12 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/{id}", "put", "422")]
[TestCase("/api/channels/{id}", "delete", "404")]
[TestCase("/api/channels/{id}", "delete", "422")]
[TestCase("/api/channels/bulk/renumber", "post", "404")]
[TestCase("/api/channels/bulk/renumber", "post", "422")]
[TestCase("/api/channels/bulk/group", "post", "404")]
[TestCase("/api/channels/bulk/group", "post", "422")]
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/collections/{id}", "get", "404")]
[TestCase("/api/collections", "post", "404")]
@@ -35,7 +35,12 @@ public abstract class ChannelHandlerTestBase
await context.SaveChangesAsync();
}
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
protected async Task SeedChannel(
int id,
string number,
string name = "Test",
int ffmpegProfileId = 1,
string group = "ErsatzTV")
{
await using TvContext context = Db.CreateContext();
context.Channels.Add(
@@ -44,7 +49,7 @@ public abstract class ChannelHandlerTestBase
Id = id,
Number = number,
Name = name,
Group = "ErsatzTV",
Group = group,
Categories = string.Empty,
FFmpegProfileId = ffmpegProfileId,
StreamSelector = string.Empty,
@@ -87,6 +87,53 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
return result.ToDeletedResult();
}
[HttpPost("/api/channels/bulk/renumber")]
[Tags("Channels")]
[EndpointSummary("Renumber channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkRenumber(
[Required] [FromBody] BulkRenumberChannelsRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[HttpPost("/api/channels/bulk/group")]
[Tags("Channels")]
[EndpointSummary("Move channels to a group")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkMoveToGroup(
[Required] [FromBody] BulkMoveChannelsToGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/channels/bulk/delete")]
[Tags("Channels")]
[EndpointSummary("Delete channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkDelete(
[Required] [FromBody] BulkDeleteChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -0,0 +1,21 @@
using ErsatzTV.Application.Channels;
namespace ErsatzTV.Controllers.Api.Requests;
public record BulkRenumberChannelRequest(int Id, string Number);
public record BulkRenumberChannelsRequest(List<BulkRenumberChannelRequest> Channels)
{
public UpdateChannelNumbers ToCommand() =>
new(Channels.Select(c => new ChannelSortViewModel { Id = c.Id, Number = c.Number }).ToList());
}
public record BulkMoveChannelsToGroupRequest(List<int> ChannelIds, string Group)
{
public BulkMoveChannelsToGroup ToCommand() => new(ChannelIds, Group);
}
public record BulkDeleteChannelsRequest(List<int> ChannelIds)
{
public BulkDeleteChannels ToCommand() => new(ChannelIds);
}
+313
View File
@@ -364,6 +364,240 @@
}
}
},
"/api/channels/bulk/renumber": {
"post": {
"tags": [
"Channels"
],
"summary": "Renumber channels",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
}
},
"required": true
},
"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/channels/bulk/group": {
"post": {
"tags": [
"Channels"
],
"summary": "Move channels to a group",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
}
},
"required": true
},
"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/channels/bulk/delete": {
"post": {
"tags": [
"Channels"
],
"summary": "Delete channels",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
}
},
"required": true
},
"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/channels/{channelNumber}/playout/reset": {
"post": {
"tags": [
@@ -3060,6 +3294,85 @@
}
}
},
"BulkDeleteChannelsRequest": {
"required": [
"channelIds"
],
"type": "object",
"properties": {
"channelIds": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
"BulkMoveChannelsToGroupRequest": {
"required": [
"channelIds",
"group"
],
"type": "object",
"properties": {
"channelIds": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
},
"group": {
"type": [
"null",
"string"
]
}
}
},
"BulkRenumberChannelRequest": {
"required": [
"id",
"number"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"number": {
"type": [
"null",
"string"
]
}
}
},
"BulkRenumberChannelsRequest": {
"required": [
"channels"
],
"type": "object",
"properties": {
"channels": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/BulkRenumberChannelRequest"
}
}
}
},
"ChannelIdleBehavior": {
"enum": [
"StopOnDisconnect",
+14
View File
@@ -21,6 +21,20 @@ export interface components {
"isExternalUrl"?: boolean;
"hasContentType"?: boolean;
"urlWithContentType"?: null | string;
};
"BulkDeleteChannelsRequest": {
"channelIds": null | Array<number>;
};
"BulkMoveChannelsToGroupRequest": {
"channelIds": null | Array<number>;
"group": null | string;
};
"BulkRenumberChannelRequest": {
"id": number;
"number": null | string;
};
"BulkRenumberChannelsRequest": {
"channels": null | Array<components["schemas"]["BulkRenumberChannelRequest"]>;
};
"ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning";
"ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles";