diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs index b38e8300f..49df9a199 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -24,8 +25,15 @@ public class CreateFFmpegProfileHandler : CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile)); + Option maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken); + return await maybeResolutionId.Match( + Some: async resolutionId => + { + Validation validation = Validate(request, resolutionId); + return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile)); + }, + None: () => Task.FromResult>( + new NotFoundError($"[Resolution] {request.ResolutionId} does not exist"))); } private async Task PersistFFmpegProfile( @@ -38,13 +46,11 @@ public class CreateFFmpegProfileHandler : return new CreateFFmpegProfileResult(ffmpegProfile.Id); } - private static async Task> Validate( - TvContext dbContext, + private static Validation Validate( CreateFFmpegProfile request, - CancellationToken cancellationToken) => - (ValidateName(request), ValidateThreadCount(request), - await ResolutionMustExist(dbContext, request, cancellationToken)) - .Apply((name, threadCount, resolutionId) => + int resolutionId) => + (ValidateName(request), ValidateThreadCount(request)) + .Apply((name, threadCount) => { var hwAccel = request.NormalizeVideo ? request.HardwareAcceleration @@ -110,12 +116,11 @@ public class CreateFFmpegProfileHandler : private static Validation ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) => createFFmpegProfile.AtLeast(0)(p => p.ThreadCount); - private static Task> ResolutionMustExist( + private static Task> ResolutionMustExist( TvContext dbContext, CreateFFmpegProfile createFFmpegProfile, CancellationToken cancellationToken) => dbContext.Resolutions .SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken) - .MapT(r => r.Id) - .Map(o => o.ToValidation($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist")); + .MapT(r => r.Id); } diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs index a7cff4b38..4a5c91e67 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; @@ -19,8 +20,19 @@ public class DeleteFFmpegProfileHandler( CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(p => DoDeletion(dbContext, p)); + Option maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken); + return await maybeProfile.Match( + Some: async profile => + { + Validation validation = await Validate( + dbContext, + request, + profile, + cancellationToken); + return await validation.Apply(p => DoDeletion(dbContext, p)); + }, + None: () => Task.FromResult>( + new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist"))); } private async Task DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile) @@ -34,19 +46,18 @@ public class DeleteFFmpegProfileHandler( private async Task> Validate( TvContext dbContext, DeleteFFmpegProfile request, + FFmpegProfile profile, CancellationToken cancellationToken) => (await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken), - await FFmpegProfileMustNotBeDefault(request, cancellationToken), - await FFmpegProfileMustExist(dbContext, request, cancellationToken)) - .Apply((_, _, ffmpegProfile) => ffmpegProfile); + await FFmpegProfileMustNotBeDefault(request, cancellationToken)) + .Apply((_, _) => profile); - private static Task> FFmpegProfileMustExist( + private static Task> FFmpegProfileMustExist( TvContext dbContext, DeleteFFmpegProfile request, CancellationToken cancellationToken) => dbContext.FFmpegProfiles - .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken) - .Map(o => o.ToValidation($"FFmpegProfile {request.FFmpegProfileId} does not exist")); + .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken); private static async Task> FFmpegProfileMustNotBeUsed( TvContext dbContext, diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs index 4966d6155..5fc566c48 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.FFmpeg.Preset; @@ -17,8 +18,22 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory dbContextFa CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken)); + Option maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken); + return await maybeProfile.Match( + Some: async profile => + { + Option maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken); + return await maybeResolutionId.Match( + Some: async _ => + { + Validation validation = await Validate(dbContext, request, profile); + return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken)); + }, + None: () => Task.FromResult>( + new NotFoundError($"[Resolution] {request.ResolutionId} does not exist"))); + }, + None: () => Task.FromResult>( + new NotFoundError("FFmpegProfile does not exist."))); } private async Task ApplyUpdateRequest( @@ -109,20 +124,16 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory dbContextFa private static async Task> Validate( TvContext dbContext, UpdateFFmpegProfile request, - CancellationToken cancellationToken) => - (await FFmpegProfileMustExist(dbContext, request, cancellationToken), - await ValidateName(dbContext, request), - ValidateThreadCount(request), - await ResolutionMustExist(dbContext, request, cancellationToken)) - .Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate); + FFmpegProfile profile) => + (await ValidateName(dbContext, request), ValidateThreadCount(request)) + .Apply((_, _) => profile); - private static Task> FFmpegProfileMustExist( + private static Task> FFmpegProfileMustExist( TvContext dbContext, UpdateFFmpegProfile updateFFmpegProfile, CancellationToken cancellationToken) => dbContext.FFmpegProfiles - .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken) - .Map(o => o.ToValidation("FFmpegProfile does not exist.")); + .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken); private static async Task> ValidateName( TvContext dbContext, @@ -147,12 +158,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory dbContextFa private static Validation ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) => updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount); - private static Task> ResolutionMustExist( + private static Task> ResolutionMustExist( TvContext dbContext, UpdateFFmpegProfile updateFFmpegProfile, CancellationToken cancellationToken) => dbContext.Resolutions .SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken) - .MapT(r => r.Id) - .Map(o => o.ToValidation($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist")); + .MapT(r => r.Id); } diff --git a/ErsatzTV.Tests/Application/FFmpegProfiles/FFmpegProfileHandlerTests.cs b/ErsatzTV.Tests/Application/FFmpegProfiles/FFmpegProfileHandlerTests.cs new file mode 100644 index 000000000..b8ee3b71f --- /dev/null +++ b/ErsatzTV.Tests/Application/FFmpegProfiles/FFmpegProfileHandlerTests.cs @@ -0,0 +1,189 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.FFmpegProfiles; + +[TestFixture] +public class FFmpegProfileHandlerTests +{ + private InMemoryTvContext _db = null!; + private IConfigElementRepository _configElementRepository = null!; + private ISearchTargets _searchTargets = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _configElementRepository = Substitute.For(); + _configElementRepository.GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.None); + _searchTargets = Substitute.For(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Create_Should_Return_NotFoundError_When_Resolution_Missing() + { + var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets); + + Either result = + await handler.Handle(MakeCreate(resolutionId: 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_NotFoundError_When_Profile_Missing() + { + var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); + + Either result = + await handler.Handle(MakeUpdate(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_NotFoundError_When_Resolution_Missing() + { + await SeedProfile(1); + var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets); + + Either result = + await handler.Handle(MakeUpdate(1, resolutionId: 999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_NotFoundError_When_Profile_Missing() + { + var handler = new DeleteFFmpegProfileHandler(_db.Factory, _configElementRepository, _searchTargets); + + Either result = await handler.Handle(new DeleteFFmpegProfile(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); + + private async Task SeedProfile(int id) + { + await using TvContext context = _db.CreateContext(); + context.FFmpegProfiles.Add(new FFmpegProfile + { + Id = id, + Name = "Default", + ThreadCount = 1, + NormalizeAudio = true, + NormalizeVideo = true, + HardwareAcceleration = HardwareAccelerationKind.None, + VaapiDisplay = "drm", + VaapiDriver = VaapiDriver.Default, + VaapiDevice = "/dev/dri/renderD128", + ResolutionId = 1, + ScalingBehavior = ScalingBehavior.ScaleAndPad, + PadMode = FilterMode.Software, + VideoFormat = FFmpegProfileVideoFormat.H264, + VideoProfile = string.Empty, + VideoPreset = string.Empty, + BitDepth = FFmpegProfileBitDepth.EightBit, + VideoBitrate = 2_000, + VideoBufferSize = 4_000, + TonemapAlgorithm = FFmpegProfileTonemapAlgorithm.Linear, + AudioFormat = FFmpegProfileAudioFormat.Aac, + AudioBitrate = 192, + AudioBufferSize = 384, + NormalizeLoudnessMode = NormalizeLoudnessMode.Off, + AudioChannels = 2, + AudioSampleRate = 48_000, + NormalizeFramerate = false, + NormalizeColors = false, + DeinterlaceVideo = false + }); + await context.SaveChangesAsync(); + } + + private static CreateFFmpegProfile MakeCreate(int resolutionId) => + new( + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + resolutionId, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); + + private static UpdateFFmpegProfile MakeUpdate(int id, int resolutionId = 1) => + new( + id, + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + resolutionId, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); +} diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 14f15bf18..e290ede9c 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -55,6 +55,16 @@ public class ApiErrorResponseMetadataTests [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status401Unauthorized)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status422UnprocessableEntity)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)] + [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)] public void Api_Error_Response_Metadata_Should_Document_ProblemDetails( Type controllerType, string actionName, @@ -69,4 +79,5 @@ public class ApiErrorResponseMetadataTests metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}"); metadata.Type.ShouldBe(typeof(ProblemDetails)); } + } diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs index 3f4776b37..73555eeff 100644 --- a/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs @@ -12,6 +12,8 @@ namespace ErsatzTV.Tests.Controllers; public class CollectionControllerSecurityTests { [TestCase(typeof(CollectionController))] + [TestCase(typeof(LibrariesController))] + [TestCase(typeof(MaintenanceController))] [TestCase(typeof(SmartCollectionController))] public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType) { @@ -23,6 +25,8 @@ public class CollectionControllerSecurityTests } [TestCase(typeof(CollectionController))] + [TestCase(typeof(LibrariesController))] + [TestCase(typeof(MaintenanceController))] [TestCase(typeof(SmartCollectionController))] public void Every_Mutating_Action_Should_Be_Protected(Type controllerType) { diff --git a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs new file mode 100644 index 000000000..6fd2111d2 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs @@ -0,0 +1,291 @@ +using System.Reflection; +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.FFmpegProfiles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Filters; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class FFmpegProfileControllerTests +{ + private FFmpegProfileController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new FFmpegProfileController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles"); + ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles/{id:int}"); + ShouldHaveActionRoute("POST", "/api/ffmpeg/profiles"); + ShouldHaveActionRoute("PUT", "/api/ffmpeg/profiles/{id:int}"); + ShouldHaveActionRoute("DELETE", "/api/ffmpeg/profiles/{id:int}"); + } + + [Test] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter() + { + ServiceFilterAttribute? filter = typeof(FFmpegProfileController) + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull("FFmpegProfileController must carry ApiKeyAuthorizationFilter at the class level"); + } + + [Test] + public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract() + { + ParameterInfo createRequest = typeof(FFmpegProfileController) + .GetMethod(nameof(FFmpegProfileController.AddOne))! + .GetParameters() + .Single(p => p.Name == "request"); + ParameterInfo updateRequest = typeof(FFmpegProfileController) + .GetMethod(nameof(FFmpegProfileController.UpdateOne))! + .GetParameters() + .Single(p => p.Name == "request"); + + createRequest.ParameterType.ShouldBe(typeof(CreateFFmpegProfileRequest)); + updateRequest.ParameterType.ShouldBe(typeof(UpdateFFmpegProfileRequest)); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + FFmpegFullProfileResponseModel vm = MakeVm(4); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + 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); + problemDetails.Title.ShouldBe("Resource not found"); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateFFmpegProfileResult(7))); + FFmpegFullProfileResponseModel vm = MakeVm(7); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/ffmpeg/profiles/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.AddOne(MakeCreateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Create_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.AddOne(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 UpdateFFmpegProfileResult(8))); + FFmpegFullProfileResponseModel vm = MakeVm(8); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.UpdateOne(8, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => c.FFmpegProfileId == 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.UpdateOne(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.DeleteProfileAsync(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.DeleteProfileAsync(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static void ShouldHaveActionRoute(string httpMethod, string route) + { + bool exists = typeof(FFmpegProfileController) + .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 FFmpegFullProfileResponseModel MakeVm(int id) => + new( + id, + "Default", + 1, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + "HD", + ScalingBehavior.ScaleAndPad, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + 2, + 48_000, + false, + false); + + private static CreateFFmpegProfileRequest MakeCreateRequest() => + new( + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + 1, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); + + private static UpdateFFmpegProfileRequest MakeUpdateRequest() => + new( + "Default", + 1, + true, + true, + HardwareAccelerationKind.None, + "drm", + VaapiDriver.Default, + "/dev/dri/renderD128", + null, + 1, + ScalingBehavior.ScaleAndPad, + FilterMode.Software, + FFmpegProfileVideoFormat.H264, + string.Empty, + string.Empty, + false, + FFmpegProfileBitDepth.EightBit, + 2_000, + 4_000, + FFmpegProfileTonemapAlgorithm.Linear, + FFmpegProfileAudioFormat.Aac, + 192, + 384, + NormalizeLoudnessMode.Off, + null, + 2, + 48_000, + false, + false, + false); +} diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 83f378b0e..71854b956 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -68,6 +68,16 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/playouts", "post", "422")] [TestCase("/api/playouts/{id}", "delete", "404")] [TestCase("/api/playouts/{id}", "delete", "422")] + [TestCase("/api/ffmpeg/profiles/{id}", "get", "404")] + [TestCase("/api/ffmpeg/profiles", "post", "404")] + [TestCase("/api/ffmpeg/profiles", "post", "401")] + [TestCase("/api/ffmpeg/profiles", "post", "422")] + [TestCase("/api/ffmpeg/profiles/{id}", "put", "404")] + [TestCase("/api/ffmpeg/profiles/{id}", "put", "401")] + [TestCase("/api/ffmpeg/profiles/{id}", "put", "422")] + [TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")] + [TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")] + [TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")] public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( string path, string method, diff --git a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs index 3699e5256..9ef312e11 100644 --- a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs +++ b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs @@ -60,7 +60,10 @@ public class ApiKeyAuthorizationFilterTests { AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null); MakeFilter(configuredKey: "secret").OnAuthorization(context); - context.Result.ShouldBeOfType(); + var result = context.Result.ShouldBeOfType(); + var problemDetails = result.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(StatusCodes.Status401Unauthorized); + problemDetails.Title.ShouldBe("Unauthorized"); } [Test] @@ -68,7 +71,10 @@ public class ApiKeyAuthorizationFilterTests { AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong"); MakeFilter(configuredKey: "secret").OnAuthorization(context); - context.Result.ShouldBeOfType(); + var result = context.Result.ShouldBeOfType(); + var problemDetails = result.Value.ShouldBeOfType(); + problemDetails.Status.ShouldBe(StatusCodes.Status401Unauthorized); + problemDetails.Title.ShouldBe("Unauthorized"); } [Test] diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs index 2dfda93b8..0e9b733fc 100644 --- a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -1,44 +1,106 @@ using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.FFmpegProfiles; +using ErsatzTV.Extensions; +using ErsatzTV.Filters; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[EndpointGroupName("general")] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class FFmpegProfileController(IMediator mediator) : ControllerBase { - [HttpGet("/api/ffmpeg/profiles", Name="GetFFmpegProfiles")] + [HttpGet("/api/ffmpeg/profiles", Name = "GetFFmpegProfiles")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Get all FFmpeg profiles")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public async Task> GetAll(CancellationToken cancellationToken) => await mediator.Send(new GetAllFFmpegProfilesForApi(), cancellationToken); - [HttpPost("/api/ffmpeg/profiles/new", Name="CreateFFmpegProfile")] + [HttpGet("/api/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Get an FFmpeg profile by id")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = + await mediator.Send(new GetFFmpegFullProfileByIdForApi(id), cancellationToken); + return result.ToGetResult(); + } + + [HttpPost("/api/ffmpeg/profiles", Name = "CreateFFmpegProfile")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Create an FFmpeg profile")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddOne( [Required] [FromBody] - CreateFFmpegProfile request, + CreateFFmpegProfileRequest request, CancellationToken cancellationToken) { - Either result = await mediator.Send(request, cancellationToken); - return result.Match(Ok, error => Problem(error.ToString())); + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async created => + { + Option profile = + await mediator.Send(new GetFFmpegFullProfileByIdForApi(created.FFmpegProfileId), cancellationToken); + return profile.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/ffmpeg/profiles/{vm.Id}", vm), + None: () => ApiResults.NotFoundProblem()); + }); } - [HttpPut("/api/ffmpeg/profiles/update", Name="UpdateFFmpegProfile")] + [HttpPut("/api/ffmpeg/profiles/{id:int}", Name = "UpdateFFmpegProfile")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Update an FFmpeg profile")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(FFmpegFullProfileResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task UpdateOne( + int id, [Required] [FromBody] - UpdateFFmpegProfile request, + UpdateFFmpegProfileRequest request, CancellationToken cancellationToken) { - Either result = await mediator.Send(request, cancellationToken); - return result.Match(Ok, error => Problem(error.ToString())); + Either result = + await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async updated => + { + Option profile = + await mediator.Send(new GetFFmpegFullProfileByIdForApi(updated.FFmpegProfileId), cancellationToken); + return profile.Match( + Some: vm => (IActionResult)new OkObjectResult(vm), + None: () => ApiResults.NotFoundProblem()); + }); } - [HttpDelete("/api/ffmpeg/delete/{id:int}", Name="DeleteFFmpegProfile")] + [HttpDelete("/api/ffmpeg/profiles/{id:int}", Name = "DeleteFFmpegProfile")] + [Tags("FFmpeg Profiles")] + [EndpointSummary("Delete an FFmpeg profile")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task DeleteProfileAsync(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken); - return result.Match(_ => Ok(), error => Conflict(error.ToString())); + return result.ToDeletedResult(); } } diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index efeadd1ae..ff76280ec 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -1,5 +1,6 @@ using ErsatzTV.Application.Libraries; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -7,6 +8,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) { [HttpPost("/api/libraries/{id:int}/scan")] diff --git a/ErsatzTV/Controllers/Api/MaintenanceController.cs b/ErsatzTV/Controllers/Api/MaintenanceController.cs index b6009e32a..26977cbac 100644 --- a/ErsatzTV/Controllers/Api/MaintenanceController.cs +++ b/ErsatzTV/Controllers/Api/MaintenanceController.cs @@ -2,6 +2,7 @@ using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Maintenance; using ErsatzTV.Core; +using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -9,6 +10,7 @@ namespace ErsatzTV.Controllers.Api; [ApiController] [EndpointGroupName("general")] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class MaintenanceController(IMediator mediator, ChannelWriter workerChannel) { [HttpGet("/api/maintenance/gc")] diff --git a/ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs new file mode 100644 index 000000000..078901d67 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs @@ -0,0 +1,71 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateFFmpegProfileRequest( + string Name, + int ThreadCount, + bool NormalizeAudio, + bool NormalizeVideo, + HardwareAccelerationKind HardwareAcceleration, + string VaapiDisplay, + VaapiDriver VaapiDriver, + string VaapiDevice, + int? QsvExtraHardwareFrames, + int ResolutionId, + ScalingBehavior ScalingBehavior, + FilterMode PadMode, + FFmpegProfileVideoFormat VideoFormat, + string VideoProfile, + string VideoPreset, + bool AllowBFrames, + FFmpegProfileBitDepth BitDepth, + int VideoBitrate, + int VideoBufferSize, + FFmpegProfileTonemapAlgorithm TonemapAlgorithm, + FFmpegProfileAudioFormat AudioFormat, + int AudioBitrate, + int AudioBufferSize, + NormalizeLoudnessMode NormalizeLoudnessMode, + double? TargetLoudness, + int AudioChannels, + int AudioSampleRate, + bool NormalizeFramerate, + bool NormalizeColors, + bool DeinterlaceVideo) +{ + public CreateFFmpegProfile ToCommand() => + new( + Name, + ThreadCount, + NormalizeAudio, + NormalizeVideo, + HardwareAcceleration, + VaapiDisplay, + VaapiDriver, + VaapiDevice, + QsvExtraHardwareFrames, + ResolutionId, + ScalingBehavior, + PadMode, + VideoFormat, + VideoProfile, + VideoPreset, + AllowBFrames, + BitDepth, + VideoBitrate, + VideoBufferSize, + TonemapAlgorithm, + AudioFormat, + AudioBitrate, + AudioBufferSize, + NormalizeLoudnessMode, + TargetLoudness, + AudioChannels, + AudioSampleRate, + NormalizeFramerate, + NormalizeColors, + DeinterlaceVideo); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegProfileRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegProfileRequest.cs new file mode 100644 index 000000000..aad06f597 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateFFmpegProfileRequest.cs @@ -0,0 +1,72 @@ +using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateFFmpegProfileRequest( + string Name, + int ThreadCount, + bool NormalizeAudio, + bool NormalizeVideo, + HardwareAccelerationKind HardwareAcceleration, + string VaapiDisplay, + VaapiDriver VaapiDriver, + string VaapiDevice, + int? QsvExtraHardwareFrames, + int ResolutionId, + ScalingBehavior ScalingBehavior, + FilterMode PadMode, + FFmpegProfileVideoFormat VideoFormat, + string VideoProfile, + string VideoPreset, + bool AllowBFrames, + FFmpegProfileBitDepth BitDepth, + int VideoBitrate, + int VideoBufferSize, + FFmpegProfileTonemapAlgorithm TonemapAlgorithm, + FFmpegProfileAudioFormat AudioFormat, + int AudioBitrate, + int AudioBufferSize, + NormalizeLoudnessMode NormalizeLoudnessMode, + double? TargetLoudness, + int AudioChannels, + int AudioSampleRate, + bool NormalizeFramerate, + bool NormalizeColors, + bool DeinterlaceVideo) +{ + public UpdateFFmpegProfile ToCommand(int id) => + new( + id, + Name, + ThreadCount, + NormalizeAudio, + NormalizeVideo, + HardwareAcceleration, + VaapiDisplay, + VaapiDriver, + VaapiDevice, + QsvExtraHardwareFrames, + ResolutionId, + ScalingBehavior, + PadMode, + VideoFormat, + VideoProfile, + VideoPreset, + AllowBFrames, + BitDepth, + VideoBitrate, + VideoBufferSize, + TonemapAlgorithm, + AudioFormat, + AudioBitrate, + AudioBufferSize, + NormalizeLoudnessMode, + TargetLoudness, + AudioChannels, + AudioSampleRate, + NormalizeFramerate, + NormalizeColors, + DeinterlaceVideo); +} diff --git a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs index f737b9cbd..e0c33c70e 100644 --- a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs +++ b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs @@ -42,7 +42,12 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided) || !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal)) { - context.Result = new UnauthorizedResult(); + context.Result = new UnauthorizedObjectResult(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = "A valid API key is required for write requests." + }); } } } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index ba1907e60..e52fa2841 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -932,8 +932,9 @@ "/api/ffmpeg/profiles": { "get": { "tags": [ - "FFmpegProfile" + "FFmpeg Profiles" ], + "summary": "Get all FFmpeg profiles", "operationId": "GetFFmpegProfiles", "responses": { "200": { @@ -966,90 +967,129 @@ } } } - } - }, - "/api/ffmpeg/profiles/new": { + }, "post": { "tags": [ - "FFmpegProfile" + "FFmpeg Profiles" ], + "summary": "Create an FFmpeg profile", "operationId": "CreateFFmpegProfile", "requestBody": { "content": { "application/json-patch+json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } }, "application/*+json": { "schema": { - "$ref": "#/components/schemas/CreateFFmpegProfile" + "$ref": "#/components/schemas/CreateFFmpegProfileRequest" } } }, "required": true }, "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/ffmpeg/profiles/update": { - "put": { - "tags": [ - "FFmpegProfile" - ], - "operationId": "UpdateFFmpegProfile", - "requestBody": { - "content": { - "application/json-patch+json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/UpdateFFmpegProfile" + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } } } }, - "required": true - }, - "responses": { - "200": { - "description": "OK" + "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/ffmpeg/delete/{id}": { - "delete": { + "/api/ffmpeg/profiles/{id}": { + "get": { "tags": [ - "FFmpegProfile" + "FFmpeg Profiles" ], - "operationId": "DeleteFFmpegProfile", + "summary": "Get an FFmpeg profile by id", + "operationId": "GetFFmpegProfileById", "parameters": [ { "name": "id", @@ -1063,7 +1103,252 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + } + } + }, + "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": [ + "FFmpeg Profiles" + ], + "summary": "Update an FFmpeg profile", + "operationId": "UpdateFFmpegProfile", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateFFmpegProfileRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/FFmpegFullProfileResponseModel" + } + } + } + }, + "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": [ + "FFmpeg Profiles" + ], + "summary": "Delete an FFmpeg profile", + "operationId": "DeleteFFmpegProfile", + "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" + } + } + } } } } @@ -3296,7 +3581,7 @@ } } }, - "CreateFFmpegProfile": { + "CreateFFmpegProfileRequest": { "required": [ "name", "threadCount", @@ -5149,9 +5434,8 @@ } } }, - "UpdateFFmpegProfile": { + "UpdateFFmpegProfileRequest": { "required": [ - "fFmpegProfileId", "name", "threadCount", "normalizeAudio", @@ -5185,10 +5469,6 @@ ], "type": "object", "properties": { - "fFmpegProfileId": { - "type": "integer", - "format": "int32" - }, "name": { "type": [ "null", @@ -5481,7 +5761,7 @@ "name": "Collections" }, { - "name": "FFmpegProfile" + "name": "FFmpeg Profiles" }, { "name": "Libraries" diff --git a/docs/rest-api.md b/docs/rest-api.md index 2644232b1..b6387e325 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -44,8 +44,10 @@ A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Valida - **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`. - `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.** -### 2.5 Two existing CRUD controllers are non-idiomatic → standardize -`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests. +### 2.5 Existing CRUD controllers are standardized +`SmartCollectionController` and `FFmpegProfileController` previously used verb-in-path routes (`/new`, +`/update`, `/delete/{id}`). They have been retrofitted to idiomatic REST routes as part of the REST #2 +slice work, gated by characterization tests and the shared `ProblemDetails` error contract. ### 2.6 Auth today A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**. @@ -116,7 +118,9 @@ New endpoints carry `[EndpointGroupName("general")]` → appear in the existing ## 4. Standardization scope -- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green). +- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green). Completed in #35/#38. +- CORS review in #38: the app still uses the existing global `AllowAll` policy. REST mutations are protected by the optional API-key write filter, and changing CORS defaults would be an operational exposure decision rather than an API-shape cleanup. Keep CORS tightening as backlog if write APIs are exposed beyond the LAN. +- Sweep result in #38: REST #2 CRUD controllers now use idiomatic routes. Older operational endpoints such as `/api/libraries/{id}/scan`, `/api/maintenance/empty_trash`, and `/api/maintenance/clean_artwork` remain outside the REST #2 CRUD standardization scope. - Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code. ## 5. Increment plan (sub-issues under #2 as tracker) @@ -127,7 +131,7 @@ One slice = one branch = one PR. PR runs `test` + `migrations` (both required); - **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first). - **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written. - **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong. -- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d. +- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d. Completed: FFmpeg profile CRUD now uses `/api/ffmpeg/profiles[/{id}]`, request DTOs, API-key write filtering, `ProblemDetails` 404/422 responses, and generated OpenAPI metadata. Deferred: configurable CORS tightening and legacy operational endpoint reshaping. **Sequencing:** #2a first (sets every convention the others copy), then #2b–#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally. @@ -140,6 +144,7 @@ One slice = one branch = one PR. PR runs `test` + `migrations` (both required); ## 7. Open items / backlog seeds - CORS tightening for mutation routes (if exposed beyond LAN). +- Legacy operational `/api` command routes (`libraries/*/scan`, maintenance actions) still use older action-style names. They are outside REST #2 CRUD and should be handled in a separate operational API cleanup if needed. - List-endpoint filtering/sorting/pagination depth (new-UI driven). - API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`). - Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local.