feat(api): filler preset + watermark editor CRUD; ffmpeg profile round-trip (#159)
Backend plumbing for the SPA transcoding editors (#143 blocker): - FFmpeg profile response DTO round-trip: add NormalizeAudio, NormalizeVideo, PadMode, TargetLoudness, NormalizeColors, ResolutionId (keep Resolution name for compat); DeinterlaceVideo now bool (was bool?). #nullable enable on the DTO. - New GET /api/ffmpeg/hardware-acceleration-kinds wrapping GetSupportedHardwareAccelerationKinds (returns enum names as strings). - Filler preset CRUD: GET-by-id/POST/PUT/DELETE on FillerPresetController with full request/response DTOs; new FillerPresetFullResponseModel + GetFillerPresetByIdForApi. CreateFillerPreset now returns the new id (CreateFillerPresetResult) to match sibling Create commands and enable a 201+Location; updated the one Blazor call site. - Watermark CRUD: GET-by-id/POST/PUT/DELETE on WatermarkController with full DTOs (path+contentType, all 15 fields); new WatermarkFullResponseModel + GetWatermarkByIdForApi. - Startup: register WatermarkLocation/WatermarkSize as OpenAPI string enums (they live in ErsatzTV.FFmpeg.State and were documented as ints, mismatching the Newtonsoft StringEnumConverter runtime serialization). - Tests: full CRUD controller tests for filler + watermark; contract-test entries (404/401/422) for the new mutating endpoints; regenerated v1.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -21,6 +22,18 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<FFmpegFullProfileResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFFmpegProfilesForApi(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/ffmpeg/hardware-acceleration-kinds", Name = "GetSupportedHardwareAccelerationKinds")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Get supported hardware acceleration kinds")]
|
||||
[EndpointDescription(
|
||||
"Returns the hardware-acceleration kinds available on this host (probed from the configured " +
|
||||
"FFmpeg binary). Always includes None; falls back to just None when FFmpeg is unavailable.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<HardwareAccelerationKind>), StatusCodes.Status200OK)]
|
||||
public async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(CancellationToken cancellationToken) =>
|
||||
// returns the enum values directly; the API serializes enums as their string names
|
||||
await mediator.Send(new GetSupportedHardwareAccelerationKinds(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Get an FFmpeg profile by id")]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,4 +20,83 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get a filler preset by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(FillerPresetFullResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<FillerPresetFullResponseModel> result =
|
||||
await mediator.Send(new GetFillerPresetByIdForApi(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/filler-presets", Name = "CreateFillerPreset")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Create a filler preset")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(FillerPresetFullResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateFillerPresetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateFillerPresetResult> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async created =>
|
||||
{
|
||||
Option<FillerPresetFullResponseModel> fillerPreset =
|
||||
await mediator.Send(new GetFillerPresetByIdForApi(created.FillerPresetId), cancellationToken);
|
||||
return fillerPreset.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/filler-presets/{vm.Id}", vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/filler-presets/{id:int}", Name = "UpdateFillerPreset")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Update a filler preset")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(FillerPresetFullResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateFillerPresetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<FillerPresetFullResponseModel> fillerPreset =
|
||||
await mediator.Send(new GetFillerPresetByIdForApi(id), cancellationToken);
|
||||
return fillerPreset.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/filler-presets/{id:int}", Name = "DeleteFillerPreset")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Delete a filler preset")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteFillerPreset(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateFillerPresetRequest(
|
||||
string Name,
|
||||
FillerKind FillerKind,
|
||||
FillerMode FillerMode,
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? PlaylistId,
|
||||
string? Expression,
|
||||
bool UseChaptersAsMediaItems)
|
||||
{
|
||||
public CreateFillerPreset ToCommand() =>
|
||||
new(
|
||||
Name,
|
||||
FillerKind,
|
||||
FillerMode,
|
||||
Duration,
|
||||
Count,
|
||||
PadToNearestMinute,
|
||||
AllowWatermarks,
|
||||
CollectionType,
|
||||
CollectionId,
|
||||
MediaItemId,
|
||||
MultiCollectionId,
|
||||
SmartCollectionId,
|
||||
PlaylistId,
|
||||
Expression ?? string.Empty,
|
||||
UseChaptersAsMediaItems);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateWatermarkRequest(
|
||||
string Name,
|
||||
ChannelWatermarkMode Mode,
|
||||
ChannelWatermarkImageSource ImageSource,
|
||||
string? Image,
|
||||
string? ImageContentType,
|
||||
WatermarkLocation Location,
|
||||
WatermarkSize Size,
|
||||
double Width,
|
||||
double HorizontalMargin,
|
||||
double VerticalMargin,
|
||||
int FrequencyMinutes,
|
||||
int DurationSeconds,
|
||||
int Opacity,
|
||||
string? OpacityExpression,
|
||||
int ZIndex,
|
||||
bool PlaceWithinSourceContent)
|
||||
{
|
||||
public CreateWatermark ToCommand() =>
|
||||
new(
|
||||
Name,
|
||||
new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty),
|
||||
Mode,
|
||||
ImageSource,
|
||||
Location,
|
||||
Size,
|
||||
Width,
|
||||
HorizontalMargin,
|
||||
VerticalMargin,
|
||||
FrequencyMinutes,
|
||||
DurationSeconds,
|
||||
Opacity,
|
||||
PlaceWithinSourceContent,
|
||||
OpacityExpression ?? string.Empty,
|
||||
ZIndex);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateFillerPresetRequest(
|
||||
string Name,
|
||||
FillerKind FillerKind,
|
||||
FillerMode FillerMode,
|
||||
TimeSpan? Duration,
|
||||
int? Count,
|
||||
int? PadToNearestMinute,
|
||||
bool AllowWatermarks,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? PlaylistId,
|
||||
string? Expression,
|
||||
bool UseChaptersAsMediaItems)
|
||||
{
|
||||
public UpdateFillerPreset ToCommand(int id) =>
|
||||
new(
|
||||
id,
|
||||
Name,
|
||||
FillerKind,
|
||||
FillerMode,
|
||||
Duration,
|
||||
Count,
|
||||
PadToNearestMinute,
|
||||
AllowWatermarks,
|
||||
CollectionType,
|
||||
CollectionId,
|
||||
MediaItemId,
|
||||
MultiCollectionId,
|
||||
SmartCollectionId,
|
||||
PlaylistId,
|
||||
Expression ?? string.Empty,
|
||||
UseChaptersAsMediaItems);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateWatermarkRequest(
|
||||
string Name,
|
||||
ChannelWatermarkMode Mode,
|
||||
ChannelWatermarkImageSource ImageSource,
|
||||
string? Image,
|
||||
string? ImageContentType,
|
||||
WatermarkLocation Location,
|
||||
WatermarkSize Size,
|
||||
double Width,
|
||||
double HorizontalMargin,
|
||||
double VerticalMargin,
|
||||
int FrequencyMinutes,
|
||||
int DurationSeconds,
|
||||
int Opacity,
|
||||
string? OpacityExpression,
|
||||
int ZIndex,
|
||||
bool PlaceWithinSourceContent)
|
||||
{
|
||||
public UpdateWatermark ToCommand(int id) =>
|
||||
new(
|
||||
id,
|
||||
Name,
|
||||
new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty),
|
||||
Mode,
|
||||
ImageSource,
|
||||
Location,
|
||||
Size,
|
||||
Width,
|
||||
HorizontalMargin,
|
||||
VerticalMargin,
|
||||
FrequencyMinutes,
|
||||
DurationSeconds,
|
||||
Opacity,
|
||||
PlaceWithinSourceContent,
|
||||
OpacityExpression ?? string.Empty,
|
||||
ZIndex);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,4 +20,83 @@ public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(List<WatermarkResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<WatermarkResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllWatermarksForApi(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/watermarks/{id:int}", Name = "GetWatermarkById")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Get a watermark by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(WatermarkFullResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<WatermarkFullResponseModel> result =
|
||||
await mediator.Send(new GetWatermarkByIdForApi(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/watermarks", Name = "CreateWatermark")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Create a watermark")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(WatermarkFullResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateWatermarkRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateWatermarkResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async created =>
|
||||
{
|
||||
Option<WatermarkFullResponseModel> watermark =
|
||||
await mediator.Send(new GetWatermarkByIdForApi(created.WatermarkId), cancellationToken);
|
||||
return watermark.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/watermarks/{vm.Id}", vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/watermarks/{id:int}", Name = "UpdateWatermark")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Update a watermark")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(WatermarkFullResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateWatermarkRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, UpdateWatermarkResult> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<WatermarkFullResponseModel> watermark =
|
||||
await mediator.Send(new GetWatermarkByIdForApi(id), cancellationToken);
|
||||
return watermark.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/watermarks/{id:int}", Name = "DeleteWatermark")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Delete a watermark")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteWatermark(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,9 +399,11 @@
|
||||
ValidationResult result = await _validator.ValidateAsync(_model, _cts.Token);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IRequest<Either<BaseError, Unit>> request = IsEdit ? _model.ToEdit() : _model.ToUpdate();
|
||||
|
||||
Seq<BaseError> errorMessage = (await Mediator.Send(request, _cts.Token)).LeftToSeq();
|
||||
// Create/Update return different result types (Update -> Unit, Create -> CreateFillerPresetResult),
|
||||
// so send each on its own branch and reduce to the shared Left error sequence.
|
||||
Seq<BaseError> errorMessage = IsEdit
|
||||
? (await Mediator.Send(_model.ToEdit(), _cts.Token)).LeftToSeq()
|
||||
: (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
|
||||
+6
-1
@@ -134,7 +134,12 @@ public class Startup
|
||||
// a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps.
|
||||
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
|
||||
.Where(type => type.IsEnum)
|
||||
.Concat([typeof(FFmpeg.OutputFormat.OutputFormatKind), typeof(Serilog.Events.LogEventLevel)])
|
||||
.Concat([
|
||||
typeof(FFmpeg.OutputFormat.OutputFormatKind),
|
||||
typeof(FFmpeg.State.WatermarkLocation),
|
||||
typeof(FFmpeg.State.WatermarkSize),
|
||||
typeof(Serilog.Events.LogEventLevel)
|
||||
])
|
||||
.GroupBy(type => type.Name)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ public class FillerPresetEditViewModel
|
||||
Expression,
|
||||
UseChaptersAsMediaItems);
|
||||
|
||||
public IRequest<Either<BaseError, Unit>> ToUpdate() =>
|
||||
public IRequest<Either<BaseError, CreateFillerPresetResult>> ToUpdate() =>
|
||||
new CreateFillerPreset(
|
||||
Name,
|
||||
FillerKind,
|
||||
|
||||
+1439
-30
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user