feat(api): standardize FFmpeg profile endpoints #52
@@ -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."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user