feat(api): standardize FFmpeg profile endpoints #52

Merged
timothy merged 1 commits from issue38-rest-api-standardization into main 2026-06-30 07:42:06 +02:00
17 changed files with 1147 additions and 111 deletions
@@ -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<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
return await maybeResolutionId.Match(
Some: async resolutionId =>
{
Validation<BaseError, FFmpegProfile> validation = Validate(request, resolutionId);
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
},
None: () => Task.FromResult<Either<BaseError, CreateFFmpegProfileResult>>(
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
}
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
@@ -38,13 +46,11 @@ public class CreateFFmpegProfileHandler :
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
}
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
private static Validation<BaseError, FFmpegProfile> 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<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private static Task<Validation<BaseError, int>> ResolutionMustExist(
private static Task<Option<int>> 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<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
.MapT(r => r.Id);
}
@@ -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<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p));
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
return await maybeProfile.Match(
Some: async profile =>
{
Validation<BaseError, FFmpegProfile> validation = await Validate(
dbContext,
request,
profile,
cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist")));
}
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
@@ -34,19 +46,18 @@ public class DeleteFFmpegProfileHandler(
private async Task<Validation<BaseError, FFmpegProfile>> 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<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
DeleteFFmpegProfile request,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken);
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
TvContext dbContext,
@@ -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<TvContext> dbContextFa
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
return await maybeProfile.Match(
Some: async profile =>
{
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
return await maybeResolutionId.Match(
Some: async _ =>
{
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, profile);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
},
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
new NotFoundError("FFmpegProfile does not exist.")));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
@@ -109,20 +124,16 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
private static async Task<Validation<BaseError, FFmpegProfile>> 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<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
@@ -147,12 +158,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private static Task<Validation<BaseError, int>> ResolutionMustExist(
private static Task<Option<int>> 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<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
.MapT(r => r.Id);
}
@@ -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<IConfigElementRepository>();
_configElementRepository.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
_searchTargets = Substitute.For<ISearchTargets>();
}
[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<BaseError, CreateFFmpegProfileResult> result =
await handler.Handle(MakeCreate(resolutionId: 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Update_Should_Return_NotFoundError_When_Profile_Missing()
{
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result =
await handler.Handle(MakeUpdate(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Update_Should_Return_NotFoundError_When_Resolution_Missing()
{
await SeedProfile(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result =
await handler.Handle(MakeUpdate(1, resolutionId: 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Return_NotFoundError_When_Profile_Missing()
{
var handler = new DeleteFFmpegProfileHandler(_db.Factory, _configElementRepository, _searchTargets);
Either<BaseError, Unit> result = await handler.Handle(new DeleteFFmpegProfile(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> 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);
}
@@ -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));
}
}
@@ -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)
{
@@ -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<IMediator>();
_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<ServiceFilterAttribute>(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<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
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<CreateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateFFmpegProfileResult>(new CreateFFmpegProfileResult(7)));
FFmpegFullProfileResponseModel vm = MakeVm(7);
_mediator.Send(Arg.Any<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.Some(vm));
IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
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<CreateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateFFmpegProfileResult>(BaseError.New("bad")));
IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Create_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<CreateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateFFmpegProfileResult>(new NotFoundError("missing")));
IActionResult result = await _controller.AddOne(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command()
{
_mediator.Send(Arg.Any<UpdateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, UpdateFFmpegProfileResult>(new UpdateFFmpegProfileResult(8)));
FFmpegFullProfileResponseModel vm = MakeVm(8);
_mediator.Send(Arg.Any<GetFFmpegFullProfileByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FFmpegFullProfileResponseModel>.Some(vm));
IActionResult result = await _controller.UpdateOne(8, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateFFmpegProfile>(c => c.FFmpegProfileId == 8),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, UpdateFFmpegProfileResult>(new NotFoundError("missing")));
IActionResult result = await _controller.UpdateOne(99, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.DeleteProfileAsync(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteFFmpegProfile>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.DeleteProfileAsync(9, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
private static void ShouldHaveActionRoute(string httpMethod, string route)
{
bool exists = typeof(FFmpegProfileController)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.SelectMany(m => m.GetCustomAttributes<HttpMethodAttribute>(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);
}
@@ -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,
@@ -60,7 +60,10 @@ public class ApiKeyAuthorizationFilterTests
{
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedResult>();
var result = context.Result.ShouldBeOfType<UnauthorizedObjectResult>();
var problemDetails = result.Value.ShouldBeOfType<ProblemDetails>();
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<UnauthorizedResult>();
var result = context.Result.ShouldBeOfType<UnauthorizedObjectResult>();
var problemDetails = result.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(StatusCodes.Status401Unauthorized);
problemDetails.Title.ShouldBe("Unauthorized");
}
[Test]
@@ -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<FFmpegFullProfileResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FFmpegFullProfileResponseModel>> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<FFmpegFullProfileResponseModel> 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<IActionResult> AddOne(
[Required] [FromBody]
CreateFFmpegProfile request,
CreateFFmpegProfileRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<FFmpegFullProfileResponseModel> 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<IActionResult> UpdateOne(
int id,
[Required] [FromBody]
UpdateFFmpegProfile request,
UpdateFFmpegProfileRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, UpdateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
Either<BaseError, UpdateFFmpegProfileResult> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async updated =>
{
Option<FFmpegFullProfileResponseModel> 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<IActionResult> DeleteProfileAsync(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Conflict(error.ToString()));
return result.ToDeletedResult();
}
}
@@ -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")]
@@ -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<IBackgroundServiceRequest> workerChannel)
{
[HttpGet("/api/maintenance/gc")]
@@ -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);
}
@@ -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);
}
@@ -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."
});
}
}
}
+339 -59
View File
@@ -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"
+9 -4
View File
@@ -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.