@@ -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."
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user