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:
2026-07-07 14:06:26 +02:00
co-authored by Claude Fable 5
parent 6e091c98ae
commit 46c8c2e2dc
27 changed files with 2392 additions and 75 deletions
@@ -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();
}
}
+5 -3
View File
@@ -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
View File
@@ -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,
File diff suppressed because it is too large Load Diff