From 46c8c2e2dcea13b88a902707b16e72e56a5a2e02 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 14:06:26 +0200 Subject: [PATCH] feat(api): filler preset + watermark editor CRUD; ffmpeg profile round-trip (#159) Backend plumbing for the SPA transcoding editors (#143 blocker): - FFmpeg profile response DTO round-trip: add NormalizeAudio, NormalizeVideo, PadMode, TargetLoudness, NormalizeColors, ResolutionId (keep Resolution name for compat); DeinterlaceVideo now bool (was bool?). #nullable enable on the DTO. - New GET /api/ffmpeg/hardware-acceleration-kinds wrapping GetSupportedHardwareAccelerationKinds (returns enum names as strings). - Filler preset CRUD: GET-by-id/POST/PUT/DELETE on FillerPresetController with full request/response DTOs; new FillerPresetFullResponseModel + GetFillerPresetByIdForApi. CreateFillerPreset now returns the new id (CreateFillerPresetResult) to match sibling Create commands and enable a 201+Location; updated the one Blazor call site. - Watermark CRUD: GET-by-id/POST/PUT/DELETE on WatermarkController with full DTOs (path+contentType, all 15 fields); new WatermarkFullResponseModel + GetWatermarkByIdForApi. - Startup: register WatermarkLocation/WatermarkSize as OpenAPI string enums (they live in ErsatzTV.FFmpeg.State and were documented as ints, mismatching the Newtonsoft StringEnumConverter runtime serialization). - Tests: full CRUD controller tests for filler + watermark; contract-test entries (404/401/422) for the new mutating endpoints; regenerated v1.json. Co-Authored-By: Claude Fable 5 --- ErsatzTV.Application/FFmpegProfiles/Mapper.cs | 8 +- .../Filler/Commands/CreateFillerPreset.cs | 4 +- .../Commands/CreateFillerPresetHandler.cs | 10 +- ErsatzTV.Application/Filler/Mapper.cs | 19 + .../Queries/GetFillerPresetByIdForApi.cs | 5 + .../GetFillerPresetByIdForApiHandler.cs | 22 + ErsatzTV.Application/Watermarks/Mapper.cs | 20 + .../Queries/GetWatermarkByIdForApi.cs | 5 + .../Queries/GetWatermarkByIdForApiHandler.cs | 22 + .../FFmpegFullProfileResponseModel.cs | 11 +- .../Filler/FillerPresetFullResponseModel.cs | 23 + .../Watermarks/WatermarkFullResponseModel.cs | 24 + .../FFmpegProfileControllerTests.cs | 6 + .../FillerPresetControllerTests.cs | 213 ++- .../OpenApiErrorResponseContractTests.cs | 20 + .../Controllers/WatermarkControllerTests.cs | 216 ++- .../Api/FFmpegProfileController.cs | 13 + .../Controllers/Api/FillerPresetController.cs | 83 + .../Api/Requests/CreateFillerPresetRequest.cs | 42 + .../Api/Requests/CreateWatermarkRequest.cs | 44 + .../Api/Requests/UpdateFillerPresetRequest.cs | 43 + .../Api/Requests/UpdateWatermarkRequest.cs | 45 + .../Controllers/Api/WatermarkController.cs | 83 + ErsatzTV/Pages/FillerPresetEditor.razor | 8 +- ErsatzTV/Startup.cs | 7 +- .../ViewModels/FillerPresetEditViewModel.cs | 2 +- ErsatzTV/wwwroot/openapi/v1.json | 1469 ++++++++++++++++- 27 files changed, 2392 insertions(+), 75 deletions(-) create mode 100644 ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApi.cs create mode 100644 ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApiHandler.cs create mode 100644 ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApi.cs create mode 100644 ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApiHandler.cs create mode 100644 ErsatzTV.Core/Api/Filler/FillerPresetFullResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Watermarks/WatermarkFullResponseModel.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateFillerPresetRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateFillerPresetRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs diff --git a/ErsatzTV.Application/FFmpegProfiles/Mapper.cs b/ErsatzTV.Application/FFmpegProfiles/Mapper.cs index 4272fdb35..2a146123d 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Mapper.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Mapper.cs @@ -52,13 +52,17 @@ internal static class Mapper ffmpegProfile.Id, ffmpegProfile.Name, ffmpegProfile.ThreadCount, + ffmpegProfile.NormalizeAudio, + ffmpegProfile.NormalizeVideo, ffmpegProfile.HardwareAcceleration, ffmpegProfile.VaapiDisplay, ffmpegProfile.VaapiDriver, ffmpegProfile.VaapiDevice, ffmpegProfile.QsvExtraHardwareFrames, + ffmpegProfile.ResolutionId, ffmpegProfile.Resolution.Name, ffmpegProfile.ScalingBehavior, + ffmpegProfile.PadMode, ffmpegProfile.VideoFormat, ffmpegProfile.VideoProfile, ffmpegProfile.VideoPreset, @@ -71,8 +75,10 @@ internal static class Mapper ffmpegProfile.AudioBitrate, ffmpegProfile.AudioBufferSize, ffmpegProfile.NormalizeLoudnessMode, + ffmpegProfile.TargetLoudness, ffmpegProfile.AudioChannels, ffmpegProfile.AudioSampleRate, ffmpegProfile.NormalizeFramerate, - ffmpegProfile.DeinterlaceVideo); + ffmpegProfile.NormalizeColors, + ffmpegProfile.DeinterlaceVideo == true); } diff --git a/ErsatzTV.Application/Filler/Commands/CreateFillerPreset.cs b/ErsatzTV.Application/Filler/Commands/CreateFillerPreset.cs index 17bd0fc41..723b57996 100644 --- a/ErsatzTV.Application/Filler/Commands/CreateFillerPreset.cs +++ b/ErsatzTV.Application/Filler/Commands/CreateFillerPreset.cs @@ -20,4 +20,6 @@ public record CreateFillerPreset( int? PlaylistId, string Expression, bool UseChaptersAsMediaItems -) : IRequest>; +) : IRequest>; + +public record CreateFillerPresetResult(int FillerPresetId) : EntityIdResult(FillerPresetId); diff --git a/ErsatzTV.Application/Filler/Commands/CreateFillerPresetHandler.cs b/ErsatzTV.Application/Filler/Commands/CreateFillerPresetHandler.cs index 5647f4bd1..ea4762f86 100644 --- a/ErsatzTV.Application/Filler/Commands/CreateFillerPresetHandler.cs +++ b/ErsatzTV.Application/Filler/Commands/CreateFillerPresetHandler.cs @@ -6,23 +6,25 @@ using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Filler; public class CreateFillerPresetHandler(IDbContextFactory dbContextFactory) - : IRequestHandler> + : IRequestHandler> { - public async Task> Handle(CreateFillerPreset request, CancellationToken cancellationToken) + public async Task> Handle( + CreateFillerPreset request, + CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(dbContext, request); return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken)); } - private static async Task Persist( + private static async Task Persist( TvContext dbContext, FillerPreset fillerPreset, CancellationToken cancellationToken) { await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - return Unit.Default; + return new CreateFillerPresetResult(fillerPreset.Id); } private static Task> Validate( diff --git a/ErsatzTV.Application/Filler/Mapper.cs b/ErsatzTV.Application/Filler/Mapper.cs index 1b72b5200..19ef528c1 100644 --- a/ErsatzTV.Application/Filler/Mapper.cs +++ b/ErsatzTV.Application/Filler/Mapper.cs @@ -8,6 +8,25 @@ internal static class Mapper internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) => new(fillerPreset.Id, fillerPreset.Name); + internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) => + new( + fillerPreset.Id, + fillerPreset.Name, + fillerPreset.FillerKind, + fillerPreset.FillerMode, + fillerPreset.Duration, + fillerPreset.Count, + fillerPreset.PadToNearestMinute, + fillerPreset.AllowWatermarks, + fillerPreset.CollectionType, + fillerPreset.CollectionId, + fillerPreset.MediaItemId, + fillerPreset.MultiCollectionId, + fillerPreset.SmartCollectionId, + fillerPreset.PlaylistId, + fillerPreset.Expression, + fillerPreset.UseChaptersAsMediaItems); + internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) => new( fillerPreset.Id, diff --git a/ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApi.cs b/ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApi.cs new file mode 100644 index 000000000..55a662209 --- /dev/null +++ b/ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Filler; + +namespace ErsatzTV.Application.Filler; + +public record GetFillerPresetByIdForApi(int Id) : IRequest>; diff --git a/ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApiHandler.cs b/ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApiHandler.cs new file mode 100644 index 000000000..f726f4841 --- /dev/null +++ b/ErsatzTV.Application/Filler/Queries/GetFillerPresetByIdForApiHandler.cs @@ -0,0 +1,22 @@ +using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Filler.Mapper; + +namespace ErsatzTV.Application.Filler; + +public class GetFillerPresetByIdForApiHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetFillerPresetByIdForApi request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.FillerPresets + .AsNoTracking() + .SelectOneAsync(fp => fp.Id, fp => fp.Id == request.Id, cancellationToken) + .MapT(ProjectToFullResponseModel); + } +} diff --git a/ErsatzTV.Application/Watermarks/Mapper.cs b/ErsatzTV.Application/Watermarks/Mapper.cs index 4869d2ddc..bf3a48cef 100644 --- a/ErsatzTV.Application/Watermarks/Mapper.cs +++ b/ErsatzTV.Application/Watermarks/Mapper.cs @@ -9,6 +9,26 @@ internal static class Mapper internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) => new(watermark.Id, watermark.Name); + internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) => + new( + watermark.Id, + watermark.Name, + watermark.Mode, + watermark.ImageSource, + watermark.Image, + watermark.OriginalContentType, + watermark.Location, + watermark.Size, + watermark.WidthPercent, + watermark.HorizontalMarginPercent, + watermark.VerticalMarginPercent, + watermark.FrequencyMinutes, + watermark.DurationSeconds, + watermark.Opacity, + watermark.OpacityExpression, + watermark.ZIndex, + watermark.PlaceWithinSourceContent); + public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) => new( watermark.Id, diff --git a/ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApi.cs b/ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApi.cs new file mode 100644 index 000000000..28b06757b --- /dev/null +++ b/ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Watermarks; + +namespace ErsatzTV.Application.Watermarks; + +public record GetWatermarkByIdForApi(int Id) : IRequest>; diff --git a/ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApiHandler.cs b/ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApiHandler.cs new file mode 100644 index 000000000..82f615ddc --- /dev/null +++ b/ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdForApiHandler.cs @@ -0,0 +1,22 @@ +using ErsatzTV.Core.Api.Watermarks; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Watermarks.Mapper; + +namespace ErsatzTV.Application.Watermarks; + +public class GetWatermarkByIdForApiHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetWatermarkByIdForApi request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.ChannelWatermarks + .AsNoTracking() + .SelectOneAsync(w => w.Id, w => w.Id == request.Id, cancellationToken) + .MapT(ProjectToFullResponseModel); + } +} diff --git a/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegFullProfileResponseModel.cs b/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegFullProfileResponseModel.cs index 706063a86..b7dc6d6ee 100644 --- a/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegFullProfileResponseModel.cs +++ b/ErsatzTV.Core/Api/FFmpegProfiles/FFmpegFullProfileResponseModel.cs @@ -1,4 +1,5 @@ -using ErsatzTV.Core.Domain; +#nullable enable +using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.Api.FFmpegProfiles; @@ -7,13 +8,17 @@ public record FFmpegFullProfileResponseModel( int Id, string Name, int ThreadCount, + bool NormalizeAudio, + bool NormalizeVideo, HardwareAccelerationKind HardwareAcceleration, string VaapiDisplay, VaapiDriver VaapiDriver, string VaapiDevice, int? QsvExtraHardwareFrames, + int ResolutionId, string Resolution, ScalingBehavior ScalingBehavior, + FilterMode PadMode, FFmpegProfileVideoFormat VideoFormat, string VideoProfile, string VideoPreset, @@ -26,7 +31,9 @@ public record FFmpegFullProfileResponseModel( int AudioBitrate, int AudioBufferSize, NormalizeLoudnessMode NormalizeLoudnessMode, + double? TargetLoudness, int AudioChannels, int AudioSampleRate, bool NormalizeFramerate, - bool? DeinterlaceVideo); + bool NormalizeColors, + bool DeinterlaceVideo); diff --git a/ErsatzTV.Core/Api/Filler/FillerPresetFullResponseModel.cs b/ErsatzTV.Core/Api/Filler/FillerPresetFullResponseModel.cs new file mode 100644 index 000000000..4aa75c4f4 --- /dev/null +++ b/ErsatzTV.Core/Api/Filler/FillerPresetFullResponseModel.cs @@ -0,0 +1,23 @@ +#nullable enable +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Core.Api.Filler; + +public record FillerPresetFullResponseModel( + int Id, + string Name, + FillerKind FillerKind, + FillerMode FillerMode, + TimeSpan? Duration, + int? Count, + int? PadToNearestMinute, + bool AllowWatermarks, + CollectionType CollectionType, + int? CollectionId, + int? MediaItemId, + int? MultiCollectionId, + int? SmartCollectionId, + int? PlaylistId, + string? Expression, + bool UseChaptersAsMediaItems); diff --git a/ErsatzTV.Core/Api/Watermarks/WatermarkFullResponseModel.cs b/ErsatzTV.Core/Api/Watermarks/WatermarkFullResponseModel.cs new file mode 100644 index 000000000..f651d59a6 --- /dev/null +++ b/ErsatzTV.Core/Api/Watermarks/WatermarkFullResponseModel.cs @@ -0,0 +1,24 @@ +#nullable enable +using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; + +namespace ErsatzTV.Core.Api.Watermarks; + +public record WatermarkFullResponseModel( + int Id, + string Name, + ChannelWatermarkMode Mode, + ChannelWatermarkImageSource ImageSource, + string? Image, + string? ImageContentType, + WatermarkLocation Location, + WatermarkSize Size, + double Width, + double HorizontalMargin, + double VerticalMargin, + int FrequencyMinutes, + int DurationSeconds, + int Opacity, + string? OpacityExpression, + int ZIndex, + bool PlaceWithinSourceContent); diff --git a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs index 657815b91..46cbd597e 100644 --- a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs @@ -188,13 +188,17 @@ public class FFmpegProfileControllerTests id, "Default", 1, + true, + true, HardwareAccelerationKind.None, "drm", VaapiDriver.Default, "/dev/dri/renderD128", null, + 1, "HD", ScalingBehavior.ScaleAndPad, + FilterMode.Software, FFmpegProfileVideoFormat.H264, string.Empty, string.Empty, @@ -207,9 +211,11 @@ public class FFmpegProfileControllerTests 192, 384, NormalizeLoudnessMode.Off, + null, 2, 48_000, false, + true, false); private static CreateFFmpegProfileRequest MakeCreateRequest() => diff --git a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs index 5747efc03..01e6df84f 100644 --- a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs @@ -1,21 +1,27 @@ using System.Reflection; using ErsatzTV.Application.Filler; using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Errors; +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 FillerPresetControllerTests { - private FillerPresetController _controller = null!; - private IMediator _mediator = null!; - [SetUp] public void SetUp() { @@ -23,15 +29,33 @@ public class FillerPresetControllerTests _controller = new FillerPresetController(_mediator); } - [Test] - public void Controller_Should_Expose_Idiomatic_Rest_Route() - { - MethodInfo action = typeof(FillerPresetController).GetMethod(nameof(FillerPresetController.GetAll)) - ?? throw new AssertionException("Missing action GetAll"); + private FillerPresetController _controller = null!; + private IMediator _mediator = null!; - HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); - attribute.HttpMethods.ShouldContain("GET"); - attribute.Template.ShouldBe("/api/filler-presets"); + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute("GET", "/api/filler-presets"); + ShouldHaveActionRoute("GET", "/api/filler-presets/{id:int}"); + ShouldHaveActionRoute("POST", "/api/filler-presets"); + ShouldHaveActionRoute("PUT", "/api/filler-presets/{id:int}"); + ShouldHaveActionRoute("DELETE", "/api/filler-presets/{id:int}"); + } + + [Test] + public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract() + { + ParameterInfo createRequest = typeof(FillerPresetController) + .GetMethod(nameof(FillerPresetController.Create))! + .GetParameters() + .Single(p => p.Name == "request"); + ParameterInfo updateRequest = typeof(FillerPresetController) + .GetMethod(nameof(FillerPresetController.Update))! + .GetParameters() + .Single(p => p.Name == "request"); + + createRequest.ParameterType.ShouldBe(typeof(CreateFillerPresetRequest)); + updateRequest.ParameterType.ShouldBe(typeof(UpdateFillerPresetRequest)); } [Test] @@ -51,13 +75,170 @@ public class FillerPresetControllerTests } [Test] - public async Task GetAll_Should_Return_Empty_List_When_None_Exist() + public async Task GetById_Should_Return_200_For_Some() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns([]); + FillerPresetFullResponseModel vm = MakeVm(4); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); - List result = await _controller.GetAll(CancellationToken.None); + IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeEmpty(); + result.ShouldBeOfType().Value.ShouldBe(vm); } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateFillerPresetResult(7))); + FillerPresetFullResponseModel vm = MakeVm(7); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/filler-presets/7"); + created.Value.ShouldBe(vm); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + FillerPresetFullResponseModel vm = MakeVm(8); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Update(8, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => c.Id == 8), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_For_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("nope"))); + + IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [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(); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static void ShouldHaveActionRoute(string httpMethod, string route) + { + bool exists = typeof(FillerPresetController) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .SelectMany(m => m.GetCustomAttributes(inherit: true)) + .Any(a => a.HttpMethods.Contains(httpMethod) && a.Template == route); + + exists.ShouldBeTrue($"Missing route {httpMethod} {route}"); + } + + private static FillerPresetFullResponseModel MakeVm(int id) => + new( + id, + "Intro", + FillerKind.PreRoll, + FillerMode.Count, + null, + 3, + null, + true, + CollectionType.Collection, + 5, + null, + null, + null, + null, + null, + false); + + private static CreateFillerPresetRequest MakeCreateRequest() => + new( + "Intro", + FillerKind.PreRoll, + FillerMode.Count, + null, + 3, + null, + true, + CollectionType.Collection, + 5, + null, + null, + null, + null, + null, + false); + + private static UpdateFillerPresetRequest MakeUpdateRequest() => + new( + "Intro", + FillerKind.PreRoll, + FillerMode.Count, + null, + 3, + null, + true, + CollectionType.Collection, + 5, + null, + null, + null, + null, + null, + false); } diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 39c6f80f7..c24174c2d 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -174,6 +174,26 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")] [TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")] [TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")] + [TestCase("/api/filler-presets/{id}", "get", "404")] + [TestCase("/api/filler-presets", "post", "401")] + [TestCase("/api/filler-presets", "post", "404")] + [TestCase("/api/filler-presets", "post", "422")] + [TestCase("/api/filler-presets/{id}", "put", "401")] + [TestCase("/api/filler-presets/{id}", "put", "404")] + [TestCase("/api/filler-presets/{id}", "put", "422")] + [TestCase("/api/filler-presets/{id}", "delete", "401")] + [TestCase("/api/filler-presets/{id}", "delete", "404")] + [TestCase("/api/filler-presets/{id}", "delete", "422")] + [TestCase("/api/watermarks/{id}", "get", "404")] + [TestCase("/api/watermarks", "post", "401")] + [TestCase("/api/watermarks", "post", "404")] + [TestCase("/api/watermarks", "post", "422")] + [TestCase("/api/watermarks/{id}", "put", "401")] + [TestCase("/api/watermarks/{id}", "put", "404")] + [TestCase("/api/watermarks/{id}", "put", "422")] + [TestCase("/api/watermarks/{id}", "delete", "401")] + [TestCase("/api/watermarks/{id}", "delete", "404")] + [TestCase("/api/watermarks/{id}", "delete", "422")] [TestCase("/api/settings/ffmpeg", "put", "401")] [TestCase("/api/settings/ffmpeg", "put", "422")] [TestCase("/api/settings/playout", "put", "401")] diff --git a/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs index 1a2bafa9b..9ae781f6b 100644 --- a/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs @@ -1,21 +1,27 @@ using System.Reflection; using ErsatzTV.Application.Watermarks; using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; using ErsatzTV.Core.Api.Watermarks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.FFmpeg.State; +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 WatermarkControllerTests { - private WatermarkController _controller = null!; - private IMediator _mediator = null!; - [SetUp] public void SetUp() { @@ -23,15 +29,33 @@ public class WatermarkControllerTests _controller = new WatermarkController(_mediator); } - [Test] - public void Controller_Should_Expose_Idiomatic_Rest_Route() - { - MethodInfo action = typeof(WatermarkController).GetMethod(nameof(WatermarkController.GetAll)) - ?? throw new AssertionException("Missing action GetAll"); + private WatermarkController _controller = null!; + private IMediator _mediator = null!; - HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); - attribute.HttpMethods.ShouldContain("GET"); - attribute.Template.ShouldBe("/api/watermarks"); + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute("GET", "/api/watermarks"); + ShouldHaveActionRoute("GET", "/api/watermarks/{id:int}"); + ShouldHaveActionRoute("POST", "/api/watermarks"); + ShouldHaveActionRoute("PUT", "/api/watermarks/{id:int}"); + ShouldHaveActionRoute("DELETE", "/api/watermarks/{id:int}"); + } + + [Test] + public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract() + { + ParameterInfo createRequest = typeof(WatermarkController) + .GetMethod(nameof(WatermarkController.Create))! + .GetParameters() + .Single(p => p.Name == "request"); + ParameterInfo updateRequest = typeof(WatermarkController) + .GetMethod(nameof(WatermarkController.Update))! + .GetParameters() + .Single(p => p.Name == "request"); + + createRequest.ParameterType.ShouldBe(typeof(CreateWatermarkRequest)); + updateRequest.ParameterType.ShouldBe(typeof(UpdateWatermarkRequest)); } [Test] @@ -51,13 +75,173 @@ public class WatermarkControllerTests } [Test] - public async Task GetAll_Should_Return_Empty_List_When_None_Exist() + public async Task GetById_Should_Return_200_For_Some() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns([]); + WatermarkFullResponseModel vm = MakeVm(4); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); - List result = await _controller.GetAll(CancellationToken.None); + IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeEmpty(); + result.ShouldBeOfType().Value.ShouldBe(vm); } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + var problemDetails = notFound.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(404); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateWatermarkResult(7))); + WatermarkFullResponseModel vm = MakeVm(7); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/watermarks/7"); + created.Value.ShouldBe(vm); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new UpdateWatermarkResult(8))); + WatermarkFullResponseModel vm = MakeVm(8); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Update(8, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => c.Id == 8), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [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(); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static void ShouldHaveActionRoute(string httpMethod, string route) + { + bool exists = typeof(WatermarkController) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .SelectMany(m => m.GetCustomAttributes(inherit: true)) + .Any(a => a.HttpMethods.Contains(httpMethod) && a.Template == route); + + exists.ShouldBeTrue($"Missing route {httpMethod} {route}"); + } + + private static WatermarkFullResponseModel MakeVm(int id) => + new( + id, + "Corner Logo", + ChannelWatermarkMode.Permanent, + ChannelWatermarkImageSource.Custom, + "abc.png", + "image/png", + WatermarkLocation.BottomRight, + WatermarkSize.Scaled, + 15, + 5, + 5, + 15, + 10, + 80, + null, + 0, + false); + + private static CreateWatermarkRequest MakeCreateRequest() => + new( + "Corner Logo", + ChannelWatermarkMode.Permanent, + ChannelWatermarkImageSource.Custom, + "abc.png", + "image/png", + WatermarkLocation.BottomRight, + WatermarkSize.Scaled, + 15, + 5, + 5, + 15, + 10, + 80, + null, + 0, + false); + + private static UpdateWatermarkRequest MakeUpdateRequest() => + new( + "Corner Logo", + ChannelWatermarkMode.Permanent, + ChannelWatermarkImageSource.Custom, + "abc.png", + "image/png", + WatermarkLocation.BottomRight, + WatermarkSize.Scaled, + 15, + 5, + 5, + 15, + 10, + 80, + null, + 0, + false); } diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs index 386967021..14e52dc75 100644 --- a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -3,6 +3,7 @@ using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.FFmpegProfiles; +using ErsatzTV.Core.Domain; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -21,6 +22,18 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase public async Task> GetAll(CancellationToken cancellationToken) => await mediator.Send(new GetAllFFmpegProfilesForApi(), cancellationToken); + [HttpGet("/api/ffmpeg/hardware-acceleration-kinds", Name = "GetSupportedHardwareAccelerationKinds")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Get supported hardware acceleration kinds")] + [EndpointDescription( + "Returns the hardware-acceleration kinds available on this host (probed from the configured " + + "FFmpeg binary). Always includes None; falls back to just None when FFmpeg is unavailable.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetHardwareAccelerationKinds(CancellationToken cancellationToken) => + // returns the enum values directly; the API serializes enums as their string names + await mediator.Send(new GetSupportedHardwareAccelerationKinds(), cancellationToken); + [HttpGet("/api/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")] [Tags("FFmpeg Profiles")] [EndpointSummary("Get an FFmpeg profile by id")] diff --git a/ErsatzTV/Controllers/Api/FillerPresetController.cs b/ErsatzTV/Controllers/Api/FillerPresetController.cs index ba4d8de77..d07ee035a 100644 --- a/ErsatzTV/Controllers/Api/FillerPresetController.cs +++ b/ErsatzTV/Controllers/Api/FillerPresetController.cs @@ -1,5 +1,9 @@ +using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.Filler; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -16,4 +20,83 @@ public class FillerPresetController(IMediator mediator) : ControllerBase [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) => await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken); + + [HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")] + [Tags("Filler Presets")] + [EndpointSummary("Get a filler preset by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FillerPresetFullResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = + await mediator.Send(new GetFillerPresetByIdForApi(id), cancellationToken); + return result.ToGetResult(); + } + + [HttpPost("/api/filler-presets", Name = "CreateFillerPreset")] + [Tags("Filler Presets")] + [EndpointSummary("Create a filler preset")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FillerPresetFullResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateFillerPresetRequest 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 fillerPreset = + await mediator.Send(new GetFillerPresetByIdForApi(created.FillerPresetId), cancellationToken); + return fillerPreset.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/filler-presets/{vm.Id}", vm), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpPut("/api/filler-presets/{id:int}", Name = "UpdateFillerPreset")] + [Tags("Filler Presets")] + [EndpointSummary("Update a filler preset")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FillerPresetFullResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Update( + int id, + [Required] [FromBody] UpdateFillerPresetRequest 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 fillerPreset = + await mediator.Send(new GetFillerPresetByIdForApi(id), cancellationToken); + return fillerPreset.Match( + Some: vm => (IActionResult)new OkObjectResult(vm), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpDelete("/api/filler-presets/{id:int}", Name = "DeleteFillerPreset")] + [Tags("Filler Presets")] + [EndpointSummary("Delete a filler preset")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteFillerPreset(id), cancellationToken); + return result.ToDeletedResult(); + } } diff --git a/ErsatzTV/Controllers/Api/Requests/CreateFillerPresetRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateFillerPresetRequest.cs new file mode 100644 index 000000000..99cef393f --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateFillerPresetRequest.cs @@ -0,0 +1,42 @@ +#nullable enable +using ErsatzTV.Application.Filler; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateFillerPresetRequest( + string Name, + FillerKind FillerKind, + FillerMode FillerMode, + TimeSpan? Duration, + int? Count, + int? PadToNearestMinute, + bool AllowWatermarks, + CollectionType CollectionType, + int? CollectionId, + int? MediaItemId, + int? MultiCollectionId, + int? SmartCollectionId, + int? PlaylistId, + string? Expression, + bool UseChaptersAsMediaItems) +{ + public CreateFillerPreset ToCommand() => + new( + Name, + FillerKind, + FillerMode, + Duration, + Count, + PadToNearestMinute, + AllowWatermarks, + CollectionType, + CollectionId, + MediaItemId, + MultiCollectionId, + SmartCollectionId, + PlaylistId, + Expression ?? string.Empty, + UseChaptersAsMediaItems); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs new file mode 100644 index 000000000..a5898e4d7 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs @@ -0,0 +1,44 @@ +#nullable enable +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Watermarks; +using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateWatermarkRequest( + string Name, + ChannelWatermarkMode Mode, + ChannelWatermarkImageSource ImageSource, + string? Image, + string? ImageContentType, + WatermarkLocation Location, + WatermarkSize Size, + double Width, + double HorizontalMargin, + double VerticalMargin, + int FrequencyMinutes, + int DurationSeconds, + int Opacity, + string? OpacityExpression, + int ZIndex, + bool PlaceWithinSourceContent) +{ + public CreateWatermark ToCommand() => + new( + Name, + new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty), + Mode, + ImageSource, + Location, + Size, + Width, + HorizontalMargin, + VerticalMargin, + FrequencyMinutes, + DurationSeconds, + Opacity, + PlaceWithinSourceContent, + OpacityExpression ?? string.Empty, + ZIndex); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateFillerPresetRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateFillerPresetRequest.cs new file mode 100644 index 000000000..1b27b7f19 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateFillerPresetRequest.cs @@ -0,0 +1,43 @@ +#nullable enable +using ErsatzTV.Application.Filler; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateFillerPresetRequest( + string Name, + FillerKind FillerKind, + FillerMode FillerMode, + TimeSpan? Duration, + int? Count, + int? PadToNearestMinute, + bool AllowWatermarks, + CollectionType CollectionType, + int? CollectionId, + int? MediaItemId, + int? MultiCollectionId, + int? SmartCollectionId, + int? PlaylistId, + string? Expression, + bool UseChaptersAsMediaItems) +{ + public UpdateFillerPreset ToCommand(int id) => + new( + id, + Name, + FillerKind, + FillerMode, + Duration, + Count, + PadToNearestMinute, + AllowWatermarks, + CollectionType, + CollectionId, + MediaItemId, + MultiCollectionId, + SmartCollectionId, + PlaylistId, + Expression ?? string.Empty, + UseChaptersAsMediaItems); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs new file mode 100644 index 000000000..d999d42c1 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs @@ -0,0 +1,45 @@ +#nullable enable +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Watermarks; +using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateWatermarkRequest( + string Name, + ChannelWatermarkMode Mode, + ChannelWatermarkImageSource ImageSource, + string? Image, + string? ImageContentType, + WatermarkLocation Location, + WatermarkSize Size, + double Width, + double HorizontalMargin, + double VerticalMargin, + int FrequencyMinutes, + int DurationSeconds, + int Opacity, + string? OpacityExpression, + int ZIndex, + bool PlaceWithinSourceContent) +{ + public UpdateWatermark ToCommand(int id) => + new( + id, + Name, + new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty), + Mode, + ImageSource, + Location, + Size, + Width, + HorizontalMargin, + VerticalMargin, + FrequencyMinutes, + DurationSeconds, + Opacity, + PlaceWithinSourceContent, + OpacityExpression ?? string.Empty, + ZIndex); +} diff --git a/ErsatzTV/Controllers/Api/WatermarkController.cs b/ErsatzTV/Controllers/Api/WatermarkController.cs index 4b2259a7f..8c8d4aa1d 100644 --- a/ErsatzTV/Controllers/Api/WatermarkController.cs +++ b/ErsatzTV/Controllers/Api/WatermarkController.cs @@ -1,5 +1,9 @@ +using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.Watermarks; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; using ErsatzTV.Core.Api.Watermarks; +using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -16,4 +20,83 @@ public class WatermarkController(IMediator mediator) : ControllerBase [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) => await mediator.Send(new GetAllWatermarksForApi(), cancellationToken); + + [HttpGet("/api/watermarks/{id:int}", Name = "GetWatermarkById")] + [Tags("Watermarks")] + [EndpointSummary("Get a watermark by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(WatermarkFullResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = + await mediator.Send(new GetWatermarkByIdForApi(id), cancellationToken); + return result.ToGetResult(); + } + + [HttpPost("/api/watermarks", Name = "CreateWatermark")] + [Tags("Watermarks")] + [EndpointSummary("Create a watermark")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(WatermarkFullResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateWatermarkRequest 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 watermark = + await mediator.Send(new GetWatermarkByIdForApi(created.WatermarkId), cancellationToken); + return watermark.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/watermarks/{vm.Id}", vm), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpPut("/api/watermarks/{id:int}", Name = "UpdateWatermark")] + [Tags("Watermarks")] + [EndpointSummary("Update a watermark")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(WatermarkFullResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Update( + int id, + [Required] [FromBody] UpdateWatermarkRequest 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 watermark = + await mediator.Send(new GetWatermarkByIdForApi(id), cancellationToken); + return watermark.Match( + Some: vm => (IActionResult)new OkObjectResult(vm), + None: () => ApiResults.NotFoundProblem()); + }); + } + + [HttpDelete("/api/watermarks/{id:int}", Name = "DeleteWatermark")] + [Tags("Watermarks")] + [EndpointSummary("Delete a watermark")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteWatermark(id), cancellationToken); + return result.ToDeletedResult(); + } } diff --git a/ErsatzTV/Pages/FillerPresetEditor.razor b/ErsatzTV/Pages/FillerPresetEditor.razor index 68fbb176b..6bc89e7fc 100644 --- a/ErsatzTV/Pages/FillerPresetEditor.razor +++ b/ErsatzTV/Pages/FillerPresetEditor.razor @@ -399,9 +399,11 @@ ValidationResult result = await _validator.ValidateAsync(_model, _cts.Token); if (result.IsValid) { - IRequest> request = IsEdit ? _model.ToEdit() : _model.ToUpdate(); - - Seq errorMessage = (await Mediator.Send(request, _cts.Token)).LeftToSeq(); + // Create/Update return different result types (Update -> Unit, Create -> CreateFillerPresetResult), + // so send each on its own branch and reduce to the shared Left error sequence. + Seq errorMessage = IsEdit + ? (await Mediator.Send(_model.ToEdit(), _cts.Token)).LeftToSeq() + : (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq(); errorMessage.HeadOrNone().Match( error => diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index a938621fb..7b5ed4fe4 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -134,7 +134,12 @@ public class Startup // a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps. Dictionary enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes() .Where(type => type.IsEnum) - .Concat([typeof(FFmpeg.OutputFormat.OutputFormatKind), typeof(Serilog.Events.LogEventLevel)]) + .Concat([ + typeof(FFmpeg.OutputFormat.OutputFormatKind), + typeof(FFmpeg.State.WatermarkLocation), + typeof(FFmpeg.State.WatermarkSize), + typeof(Serilog.Events.LogEventLevel) + ]) .GroupBy(type => type.Name) .ToDictionary(group => group.Key, group => group.First()); diff --git a/ErsatzTV/ViewModels/FillerPresetEditViewModel.cs b/ErsatzTV/ViewModels/FillerPresetEditViewModel.cs index f4f75929f..7905119c3 100644 --- a/ErsatzTV/ViewModels/FillerPresetEditViewModel.cs +++ b/ErsatzTV/ViewModels/FillerPresetEditViewModel.cs @@ -159,7 +159,7 @@ public class FillerPresetEditViewModel Expression, UseChaptersAsMediaItems); - public IRequest> ToUpdate() => + public IRequest> ToUpdate() => new CreateFillerPreset( Name, FillerKind, diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 2b7057dc0..ad7d52319 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2086,6 +2086,47 @@ } } }, + "/api/ffmpeg/hardware-acceleration-kinds": { + "get": { + "tags": [ + "FFmpeg Profiles" + ], + "summary": "Get supported hardware acceleration kinds", + "description": "Returns the hardware-acceleration kinds available on this host (probed from the configured FFmpeg binary). Always includes None; falls back to just None when FFmpeg is unavailable.", + "operationId": "GetSupportedHardwareAccelerationKinds", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HardwareAccelerationKind" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HardwareAccelerationKind" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HardwareAccelerationKind" + } + } + } + } + } + } + } + }, "/api/ffmpeg/profiles/{id}": { "get": { "tags": [ @@ -2394,6 +2435,390 @@ } } } + }, + "post": { + "tags": [ + "Filler Presets" + ], + "summary": "Create a filler preset", + "operationId": "CreateFillerPreset", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateFillerPresetRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFillerPresetRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateFillerPresetRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateFillerPresetRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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/filler-presets/{id}": { + "get": { + "tags": [ + "Filler Presets" + ], + "summary": "Get a filler preset by id", + "operationId": "GetFillerPresetById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Filler Presets" + ], + "summary": "Update a filler preset", + "operationId": "UpdateFillerPreset", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFillerPresetRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFillerPresetRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFillerPresetRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFillerPresetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FillerPresetFullResponseModel" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Filler Presets" + ], + "summary": "Delete a filler preset", + "operationId": "DeleteFillerPreset", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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/graphics-elements": { @@ -5501,6 +5926,390 @@ } } } + }, + "post": { + "tags": [ + "Watermarks" + ], + "summary": "Create a watermark", + "operationId": "CreateWatermark", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateWatermarkRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWatermarkRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateWatermarkRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateWatermarkRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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/watermarks/{id}": { + "get": { + "tags": [ + "Watermarks" + ], + "summary": "Get a watermark by id", + "operationId": "GetWatermarkById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Watermarks" + ], + "summary": "Update a watermark", + "operationId": "UpdateWatermark", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateWatermarkRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWatermarkRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWatermarkRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateWatermarkRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/WatermarkFullResponseModel" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Watermarks" + ], + "summary": "Delete a watermark", + "operationId": "DeleteWatermark", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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" + } + } + } + } + } } } }, @@ -7206,6 +8015,108 @@ } } }, + "CreateFillerPresetRequest": { + "required": [ + "name", + "fillerKind", + "fillerMode", + "duration", + "count", + "padToNearestMinute", + "allowWatermarks", + "collectionType", + "collectionId", + "mediaItemId", + "multiCollectionId", + "smartCollectionId", + "playlistId", + "expression", + "useChaptersAsMediaItems" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "fillerKind": { + "$ref": "#/components/schemas/FillerKind" + }, + "fillerMode": { + "$ref": "#/components/schemas/FillerMode" + }, + "duration": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "count": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "padToNearestMinute": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "allowWatermarks": { + "type": "boolean" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "expression": { + "type": [ + "null", + "string" + ] + }, + "useChaptersAsMediaItems": { + "type": "boolean" + } + } + }, "CreatePlayoutRequest": { "required": [ "channelId", @@ -7295,18 +8206,109 @@ } } }, + "CreateWatermarkRequest": { + "required": [ + "name", + "mode", + "imageSource", + "image", + "imageContentType", + "location", + "size", + "width", + "horizontalMargin", + "verticalMargin", + "frequencyMinutes", + "durationSeconds", + "opacity", + "opacityExpression", + "zIndex", + "placeWithinSourceContent" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "mode": { + "$ref": "#/components/schemas/ChannelWatermarkMode" + }, + "imageSource": { + "$ref": "#/components/schemas/ChannelWatermarkImageSource" + }, + "image": { + "type": [ + "null", + "string" + ] + }, + "imageContentType": { + "type": [ + "null", + "string" + ] + }, + "location": { + "$ref": "#/components/schemas/WatermarkLocation" + }, + "size": { + "$ref": "#/components/schemas/WatermarkSize" + }, + "width": { + "type": "number", + "format": "double" + }, + "horizontalMargin": { + "type": "number", + "format": "double" + }, + "verticalMargin": { + "type": "number", + "format": "double" + }, + "frequencyMinutes": { + "type": "integer", + "format": "int32" + }, + "durationSeconds": { + "type": "integer", + "format": "int32" + }, + "opacity": { + "type": "integer", + "format": "int32" + }, + "opacityExpression": { + "type": [ + "null", + "string" + ] + }, + "zIndex": { + "type": "integer", + "format": "int32" + }, + "placeWithinSourceContent": { + "type": "boolean" + } + } + }, "FFmpegFullProfileResponseModel": { "required": [ "id", "name", "threadCount", + "normalizeAudio", + "normalizeVideo", "hardwareAcceleration", "vaapiDisplay", "vaapiDriver", "vaapiDevice", "qsvExtraHardwareFrames", + "resolutionId", "resolution", "scalingBehavior", + "padMode", "videoFormat", "videoProfile", "videoPreset", @@ -7319,9 +8321,11 @@ "audioBitrate", "audioBufferSize", "normalizeLoudnessMode", + "targetLoudness", "audioChannels", "audioSampleRate", "normalizeFramerate", + "normalizeColors", "deinterlaceVideo" ], "type": "object", @@ -7331,32 +8335,29 @@ "format": "int32" }, "name": { - "type": [ - "null", - "string" - ] + "type": "string" }, "threadCount": { "type": "integer", "format": "int32" }, + "normalizeAudio": { + "type": "boolean" + }, + "normalizeVideo": { + "type": "boolean" + }, "hardwareAcceleration": { "$ref": "#/components/schemas/HardwareAccelerationKind" }, "vaapiDisplay": { - "type": [ - "null", - "string" - ] + "type": "string" }, "vaapiDriver": { "$ref": "#/components/schemas/VaapiDriver" }, "vaapiDevice": { - "type": [ - "null", - "string" - ] + "type": "string" }, "qsvExtraHardwareFrames": { "type": [ @@ -7365,29 +8366,27 @@ ], "format": "int32" }, + "resolutionId": { + "type": "integer", + "format": "int32" + }, "resolution": { - "type": [ - "null", - "string" - ] + "type": "string" }, "scalingBehavior": { "$ref": "#/components/schemas/ScalingBehavior" }, + "padMode": { + "$ref": "#/components/schemas/FilterMode" + }, "videoFormat": { "$ref": "#/components/schemas/FFmpegProfileVideoFormat" }, "videoProfile": { - "type": [ - "null", - "string" - ] + "type": "string" }, "videoPreset": { - "type": [ - "null", - "string" - ] + "type": "string" }, "allowBFrames": { "type": "boolean" @@ -7420,6 +8419,13 @@ "normalizeLoudnessMode": { "$ref": "#/components/schemas/NormalizeLoudnessMode" }, + "targetLoudness": { + "type": [ + "null", + "number" + ], + "format": "double" + }, "audioChannels": { "type": "integer", "format": "int32" @@ -7431,11 +8437,11 @@ "normalizeFramerate": { "type": "boolean" }, + "normalizeColors": { + "type": "boolean" + }, "deinterlaceVideo": { - "type": [ - "null", - "boolean" - ] + "type": "boolean" } } }, @@ -7580,6 +8586,113 @@ ], "type": "string" }, + "FillerPresetFullResponseModel": { + "required": [ + "id", + "name", + "fillerKind", + "fillerMode", + "duration", + "count", + "padToNearestMinute", + "allowWatermarks", + "collectionType", + "collectionId", + "mediaItemId", + "multiCollectionId", + "smartCollectionId", + "playlistId", + "expression", + "useChaptersAsMediaItems" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "fillerKind": { + "$ref": "#/components/schemas/FillerKind" + }, + "fillerMode": { + "$ref": "#/components/schemas/FillerMode" + }, + "duration": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "count": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "padToNearestMinute": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "allowWatermarks": { + "type": "boolean" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "expression": { + "type": [ + "null", + "string" + ] + }, + "useChaptersAsMediaItems": { + "type": "boolean" + } + } + }, "FillerPresetResponseModel": { "required": [ "id", @@ -10047,6 +11160,108 @@ } } }, + "UpdateFillerPresetRequest": { + "required": [ + "name", + "fillerKind", + "fillerMode", + "duration", + "count", + "padToNearestMinute", + "allowWatermarks", + "collectionType", + "collectionId", + "mediaItemId", + "multiCollectionId", + "smartCollectionId", + "playlistId", + "expression", + "useChaptersAsMediaItems" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "fillerKind": { + "$ref": "#/components/schemas/FillerKind" + }, + "fillerMode": { + "$ref": "#/components/schemas/FillerMode" + }, + "duration": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "count": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "padToNearestMinute": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "allowWatermarks": { + "type": "boolean" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "expression": { + "type": [ + "null", + "string" + ] + }, + "useChaptersAsMediaItems": { + "type": "boolean" + } + } + }, "UpdateHdhrSettingsRequest": { "required": [ "tunerCount" @@ -10196,6 +11411,93 @@ } } }, + "UpdateWatermarkRequest": { + "required": [ + "name", + "mode", + "imageSource", + "image", + "imageContentType", + "location", + "size", + "width", + "horizontalMargin", + "verticalMargin", + "frequencyMinutes", + "durationSeconds", + "opacity", + "opacityExpression", + "zIndex", + "placeWithinSourceContent" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "mode": { + "$ref": "#/components/schemas/ChannelWatermarkMode" + }, + "imageSource": { + "$ref": "#/components/schemas/ChannelWatermarkImageSource" + }, + "image": { + "type": [ + "null", + "string" + ] + }, + "imageContentType": { + "type": [ + "null", + "string" + ] + }, + "location": { + "$ref": "#/components/schemas/WatermarkLocation" + }, + "size": { + "$ref": "#/components/schemas/WatermarkSize" + }, + "width": { + "type": "number", + "format": "double" + }, + "horizontalMargin": { + "type": "number", + "format": "double" + }, + "verticalMargin": { + "type": "number", + "format": "double" + }, + "frequencyMinutes": { + "type": "integer", + "format": "int32" + }, + "durationSeconds": { + "type": "integer", + "format": "int32" + }, + "opacity": { + "type": "integer", + "format": "int32" + }, + "opacityExpression": { + "type": [ + "null", + "string" + ] + }, + "zIndex": { + "type": "integer", + "format": "int32" + }, + "placeWithinSourceContent": { + "type": "boolean" + } + } + }, "UpdateXmltvSettingsRequest": { "required": [ "daysToBuild", @@ -10226,8 +11528,111 @@ ], "type": "string" }, + "WatermarkFullResponseModel": { + "required": [ + "id", + "name", + "mode", + "imageSource", + "image", + "imageContentType", + "location", + "size", + "width", + "horizontalMargin", + "verticalMargin", + "frequencyMinutes", + "durationSeconds", + "opacity", + "opacityExpression", + "zIndex", + "placeWithinSourceContent" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + }, + "mode": { + "$ref": "#/components/schemas/ChannelWatermarkMode" + }, + "imageSource": { + "$ref": "#/components/schemas/ChannelWatermarkImageSource" + }, + "image": { + "type": [ + "null", + "string" + ] + }, + "imageContentType": { + "type": [ + "null", + "string" + ] + }, + "location": { + "$ref": "#/components/schemas/WatermarkLocation" + }, + "size": { + "$ref": "#/components/schemas/WatermarkSize" + }, + "width": { + "type": "number", + "format": "double" + }, + "horizontalMargin": { + "type": "number", + "format": "double" + }, + "verticalMargin": { + "type": "number", + "format": "double" + }, + "frequencyMinutes": { + "type": "integer", + "format": "int32" + }, + "durationSeconds": { + "type": "integer", + "format": "int32" + }, + "opacity": { + "type": "integer", + "format": "int32" + }, + "opacityExpression": { + "type": [ + "null", + "string" + ] + }, + "zIndex": { + "type": "integer", + "format": "int32" + }, + "placeWithinSourceContent": { + "type": "boolean" + } + } + }, "WatermarkLocation": { - "type": "integer" + "enum": [ + "BottomRight", + "BottomLeft", + "TopRight", + "TopLeft", + "TopMiddle", + "RightMiddle", + "BottomMiddle", + "LeftMiddle", + "MiddleCenter" + ], + "type": "string" }, "WatermarkResponseModel": { "required": [ @@ -10249,7 +11654,11 @@ } }, "WatermarkSize": { - "type": "integer" + "enum": [ + "Scaled", + "ActualSize" + ], + "type": "string" }, "WatermarkViewModel": { "required": [