Merge remote-tracking branch 'origin/main' into feat/142-trakt-lists
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

# Conflicts:
#	ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs
#	web/src/App.tsx
#	web/src/screens/SettingsScreen.tsx
This commit is contained in:
2026-07-07 15:13:22 +02:00
46 changed files with 5263 additions and 133 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