feat(api): standardize ffmpeg profile endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m13s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

refs #38
This commit is contained in:
2026-06-30 07:29:47 +02:00
parent 30ecd9a8a7
commit 861827dcbc
17 changed files with 1147 additions and 111 deletions
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -24,8 +25,15 @@ public class CreateFFmpegProfileHandler :
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken); Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile)); 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( private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
@@ -38,13 +46,11 @@ public class CreateFFmpegProfileHandler :
return new CreateFFmpegProfileResult(ffmpegProfile.Id); return new CreateFFmpegProfileResult(ffmpegProfile.Id);
} }
private static async Task<Validation<BaseError, FFmpegProfile>> Validate( private static Validation<BaseError, FFmpegProfile> Validate(
TvContext dbContext,
CreateFFmpegProfile request, CreateFFmpegProfile request,
CancellationToken cancellationToken) => int resolutionId) =>
(ValidateName(request), ValidateThreadCount(request), (ValidateName(request), ValidateThreadCount(request))
await ResolutionMustExist(dbContext, request, cancellationToken)) .Apply((name, threadCount) =>
.Apply((name, threadCount, resolutionId) =>
{ {
var hwAccel = request.NormalizeVideo var hwAccel = request.NormalizeVideo
? request.HardwareAcceleration ? request.HardwareAcceleration
@@ -110,12 +116,11 @@ public class CreateFFmpegProfileHandler :
private static Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) => private static Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount); createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private static Task<Validation<BaseError, int>> ResolutionMustExist( private static Task<Option<int>> ResolutionMustExist(
TvContext dbContext, TvContext dbContext,
CreateFFmpegProfile createFFmpegProfile, CreateFFmpegProfile createFFmpegProfile,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.Resolutions dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken) .SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken)
.MapT(r => r.Id) .MapT(r => r.Id);
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
} }
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -19,8 +20,19 @@ public class DeleteFFmpegProfileHandler(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken); Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p)); 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) private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
@@ -34,19 +46,18 @@ public class DeleteFFmpegProfileHandler(
private async Task<Validation<BaseError, FFmpegProfile>> Validate( private async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext, TvContext dbContext,
DeleteFFmpegProfile request, DeleteFFmpegProfile request,
FFmpegProfile profile,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken), (await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
await FFmpegProfileMustNotBeDefault(request, cancellationToken), await FFmpegProfileMustNotBeDefault(request, cancellationToken))
await FFmpegProfileMustExist(dbContext, request, cancellationToken)) .Apply((_, _) => profile);
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist( private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext, TvContext dbContext,
DeleteFFmpegProfile request, DeleteFFmpegProfile request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken);
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed( private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
TvContext dbContext, TvContext dbContext,
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg.Preset; using ErsatzTV.FFmpeg.Preset;
@@ -17,8 +18,22 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken); Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, 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( private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
@@ -109,20 +124,16 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
private static async Task<Validation<BaseError, FFmpegProfile>> Validate( private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext, TvContext dbContext,
UpdateFFmpegProfile request, UpdateFFmpegProfile request,
CancellationToken cancellationToken) => FFmpegProfile profile) =>
(await FFmpegProfileMustExist(dbContext, request, cancellationToken), (await ValidateName(dbContext, request), ValidateThreadCount(request))
await ValidateName(dbContext, request), .Apply((_, _) => profile);
ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist( private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext, TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile, UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken);
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName( private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext, TvContext dbContext,
@@ -147,12 +158,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) => private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount); updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private static Task<Validation<BaseError, int>> ResolutionMustExist( private static Task<Option<int>> ResolutionMustExist(
TvContext dbContext, TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile, UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.Resolutions dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken) .SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
.MapT(r => r.Id) .MapT(r => r.Id);
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
} }
@@ -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.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)] [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( public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
Type controllerType, Type controllerType,
string actionName, string actionName,
@@ -69,4 +79,5 @@ public class ApiErrorResponseMetadataTests
metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}"); metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}");
metadata.Type.ShouldBe(typeof(ProblemDetails)); metadata.Type.ShouldBe(typeof(ProblemDetails));
} }
} }
@@ -12,6 +12,8 @@ namespace ErsatzTV.Tests.Controllers;
public class CollectionControllerSecurityTests public class CollectionControllerSecurityTests
{ {
[TestCase(typeof(CollectionController))] [TestCase(typeof(CollectionController))]
[TestCase(typeof(LibrariesController))]
[TestCase(typeof(MaintenanceController))]
[TestCase(typeof(SmartCollectionController))] [TestCase(typeof(SmartCollectionController))]
public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType) public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType)
{ {
@@ -23,6 +25,8 @@ public class CollectionControllerSecurityTests
} }
[TestCase(typeof(CollectionController))] [TestCase(typeof(CollectionController))]
[TestCase(typeof(LibrariesController))]
[TestCase(typeof(MaintenanceController))]
[TestCase(typeof(SmartCollectionController))] [TestCase(typeof(SmartCollectionController))]
public void Every_Mutating_Action_Should_Be_Protected(Type controllerType) 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", "post", "422")]
[TestCase("/api/playouts/{id}", "delete", "404")] [TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "422")] [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( public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path, string path,
string method, string method,
@@ -60,7 +60,10 @@ public class ApiKeyAuthorizationFilterTests
{ {
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null); AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
MakeFilter(configuredKey: "secret").OnAuthorization(context); 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] [Test]
@@ -68,7 +71,10 @@ public class ApiKeyAuthorizationFilterTests
{ {
AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong"); AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong");
MakeFilter(configuredKey: "secret").OnAuthorization(context); 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] [Test]
@@ -1,44 +1,106 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Api.FFmpegProfiles; using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api; namespace ErsatzTV.Controllers.Api;
[ApiController] [ApiController]
[EndpointGroupName("general")] [ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class FFmpegProfileController(IMediator mediator) : ControllerBase 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) => public async Task<List<FFmpegFullProfileResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFFmpegProfilesForApi(), 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( public async Task<IActionResult> AddOne(
[Required] [FromBody] [Required] [FromBody]
CreateFFmpegProfile request, CreateFFmpegProfileRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken); Either<BaseError, CreateFFmpegProfileResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString())); 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( public async Task<IActionResult> UpdateOne(
int id,
[Required] [FromBody] [Required] [FromBody]
UpdateFFmpegProfile request, UpdateFFmpegProfileRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Either<BaseError, UpdateFFmpegProfileResult> result = await mediator.Send(request, cancellationToken); Either<BaseError, UpdateFFmpegProfileResult> result =
return result.Match<IActionResult>(Ok, error => Problem(error.ToString())); 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) public async Task<IActionResult> DeleteProfileAsync(int id, CancellationToken cancellationToken)
{ {
Either<BaseError, Unit> result = await mediator.Send(new DeleteFFmpegProfile(id), 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.Application.Libraries;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Filters;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -7,6 +8,7 @@ namespace ErsatzTV.Controllers.Api;
[ApiController] [ApiController]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
{ {
[HttpPost("/api/libraries/{id:int}/scan")] [HttpPost("/api/libraries/{id:int}/scan")]
@@ -2,6 +2,7 @@ using System.Threading.Channels;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Filters;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -9,6 +10,7 @@ namespace ErsatzTV.Controllers.Api;
[ApiController] [ApiController]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class MaintenanceController(IMediator mediator, ChannelWriter<IBackgroundServiceRequest> workerChannel) public class MaintenanceController(IMediator mediator, ChannelWriter<IBackgroundServiceRequest> workerChannel)
{ {
[HttpGet("/api/maintenance/gc")] [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) if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided)
|| !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal)) || !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": { "/api/ffmpeg/profiles": {
"get": { "get": {
"tags": [ "tags": [
"FFmpegProfile" "FFmpeg Profiles"
], ],
"summary": "Get all FFmpeg profiles",
"operationId": "GetFFmpegProfiles", "operationId": "GetFFmpegProfiles",
"responses": { "responses": {
"200": { "200": {
@@ -966,90 +967,129 @@
} }
} }
} }
} },
},
"/api/ffmpeg/profiles/new": {
"post": { "post": {
"tags": [ "tags": [
"FFmpegProfile" "FFmpeg Profiles"
], ],
"summary": "Create an FFmpeg profile",
"operationId": "CreateFFmpegProfile", "operationId": "CreateFFmpegProfile",
"requestBody": { "requestBody": {
"content": { "content": {
"application/json-patch+json": { "application/json-patch+json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateFFmpegProfile" "$ref": "#/components/schemas/CreateFFmpegProfileRequest"
} }
}, },
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateFFmpegProfile" "$ref": "#/components/schemas/CreateFFmpegProfileRequest"
} }
}, },
"text/json": { "text/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateFFmpegProfile" "$ref": "#/components/schemas/CreateFFmpegProfileRequest"
} }
}, },
"application/*+json": { "application/*+json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateFFmpegProfile" "$ref": "#/components/schemas/CreateFFmpegProfileRequest"
} }
} }
}, },
"required": true "required": true
}, },
"responses": { "responses": {
"200": { "201": {
"description": "OK" "description": "Created",
} "content": {
} "text/plain": {
} "schema": {
}, "$ref": "#/components/schemas/FFmpegFullProfileResponseModel"
"/api/ffmpeg/profiles/update": { }
"put": { },
"tags": [ "application/json": {
"FFmpegProfile" "schema": {
], "$ref": "#/components/schemas/FFmpegFullProfileResponseModel"
"operationId": "UpdateFFmpegProfile", }
"requestBody": { },
"content": { "text/json": {
"application/json-patch+json": { "schema": {
"schema": { "$ref": "#/components/schemas/FFmpegFullProfileResponseModel"
"$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"
} }
} }
}, },
"required": true "401": {
}, "description": "Unauthorized",
"responses": { "content": {
"200": { "text/plain": {
"description": "OK" "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}": { "/api/ffmpeg/profiles/{id}": {
"delete": { "get": {
"tags": [ "tags": [
"FFmpegProfile" "FFmpeg Profiles"
], ],
"operationId": "DeleteFFmpegProfile", "summary": "Get an FFmpeg profile by id",
"operationId": "GetFFmpegProfileById",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@@ -1063,7 +1103,252 @@
], ],
"responses": { "responses": {
"200": { "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": [ "required": [
"name", "name",
"threadCount", "threadCount",
@@ -5149,9 +5434,8 @@
} }
} }
}, },
"UpdateFFmpegProfile": { "UpdateFFmpegProfileRequest": {
"required": [ "required": [
"fFmpegProfileId",
"name", "name",
"threadCount", "threadCount",
"normalizeAudio", "normalizeAudio",
@@ -5185,10 +5469,6 @@
], ],
"type": "object", "type": "object",
"properties": { "properties": {
"fFmpegProfileId": {
"type": "integer",
"format": "int32"
},
"name": { "name": {
"type": [ "type": [
"null", "null",
@@ -5481,7 +5761,7 @@
"name": "Collections" "name": "Collections"
}, },
{ {
"name": "FFmpegProfile" "name": "FFmpeg Profiles"
}, },
{ {
"name": "Libraries" "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")]`. - **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.** - `.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 ### 2.5 Existing CRUD controllers are standardized
`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. `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 ### 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**. 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 ## 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. - 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) ## 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). - **#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. - **#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. - **#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. **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 ## 7. Open items / backlog seeds
- CORS tightening for mutation routes (if exposed beyond LAN). - 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). - 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`). - 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. - Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local.