feat(web+api): FFmpeg profile, filler preset & watermark editors for SPA parity (#143) #166

Merged
timothy merged 5 commits from feat/143-transcoding-editors into main 2026-07-07 15:06:39 +02:00
46 changed files with 5263 additions and 133 deletions
@@ -52,13 +52,17 @@ internal static class Mapper
ffmpegProfile.Id,
ffmpegProfile.Name,
ffmpegProfile.ThreadCount,
ffmpegProfile.NormalizeAudio,
ffmpegProfile.NormalizeVideo,
ffmpegProfile.HardwareAcceleration,
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
ffmpegProfile.QsvExtraHardwareFrames,
ffmpegProfile.ResolutionId,
ffmpegProfile.Resolution.Name,
ffmpegProfile.ScalingBehavior,
ffmpegProfile.PadMode,
ffmpegProfile.VideoFormat,
ffmpegProfile.VideoProfile,
ffmpegProfile.VideoPreset,
@@ -71,8 +75,10 @@ internal static class Mapper
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.TargetLoudness,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo);
ffmpegProfile.NormalizeColors,
ffmpegProfile.DeinterlaceVideo == true);
}
@@ -20,4 +20,6 @@ public record CreateFillerPreset(
int? PlaylistId,
string Expression,
bool UseChaptersAsMediaItems
) : IRequest<Either<BaseError, Unit>>;
) : IRequest<Either<BaseError, CreateFillerPresetResult>>;
public record CreateFillerPresetResult(int FillerPresetId) : EntityIdResult(FillerPresetId);
@@ -6,23 +6,25 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Filler;
public class CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
: IRequestHandler<CreateFillerPreset, Either<BaseError, CreateFillerPresetResult>>
{
public async Task<Either<BaseError, Unit>> Handle(CreateFillerPreset request, CancellationToken cancellationToken)
public async Task<Either<BaseError, CreateFillerPresetResult>> Handle(
CreateFillerPreset request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request);
return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken));
}
private static async Task<Unit> Persist(
private static async Task<CreateFillerPresetResult> Persist(
TvContext dbContext,
FillerPreset fillerPreset,
CancellationToken cancellationToken)
{
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
return new CreateFillerPresetResult(fillerPreset.Id);
}
private static Task<Validation<BaseError, FillerPreset>> Validate(
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -18,8 +19,14 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request, cancellationToken);
return await validation.Apply(ps => DoDeletion(dbContext, ps));
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeFillerPreset.Match(
Some: fillerPreset => DoDeletion(dbContext, fillerPreset).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FillerPreset {request.FillerPresetId} does not exist.")));
}
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
@@ -28,11 +35,10 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
private static Task<Option<FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
DeleteFillerPreset request,
CancellationToken cancellationToken) =>
dbContext.FillerPresets
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FillerPreset {request.FillerPresetId} does not exist."));
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken);
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -12,8 +13,19 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeFillerPreset.Match(
Some: async fillerPreset =>
{
Validation<BaseError, string> validation = await ValidateName(dbContext, request);
return await validation.Apply((string _) =>
ApplyUpdateRequest(dbContext, fillerPreset, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FillerPreset {request.Id} does not exist.")));
}
private static async Task<Unit> ApplyUpdateRequest(
@@ -44,20 +56,12 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
return Unit.Default;
}
private static async Task<Validation<BaseError, FillerPreset>> Validate(
TvContext dbContext,
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
(await FillerPresetMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
private static Task<Option<FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
dbContext.FillerPresets
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Filler preset does not exist"));
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
+19
View File
@@ -8,6 +8,25 @@ internal static class Mapper
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name);
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
fillerPreset.Name,
fillerPreset.FillerKind,
fillerPreset.FillerMode,
fillerPreset.Duration,
fillerPreset.Count,
fillerPreset.PadToNearestMinute,
fillerPreset.AllowWatermarks,
fillerPreset.CollectionType,
fillerPreset.CollectionId,
fillerPreset.MediaItemId,
fillerPreset.MultiCollectionId,
fillerPreset.SmartCollectionId,
fillerPreset.PlaylistId,
fillerPreset.Expression,
fillerPreset.UseChaptersAsMediaItems);
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Filler;
namespace ErsatzTV.Application.Filler;
public record GetFillerPresetByIdForApi(int Id) : IRequest<Option<FillerPresetFullResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler;
public class GetFillerPresetByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetFillerPresetByIdForApi, Option<FillerPresetFullResponseModel>>
{
public async Task<Option<FillerPresetFullResponseModel>> Handle(
GetFillerPresetByIdForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FillerPresets
.AsNoTracking()
.SelectOneAsync(fp => fp.Id, fp => fp.Id == request.Id, cancellationToken)
.MapT(ProjectToFullResponseModel);
}
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -23,11 +24,14 @@ public class DeleteWatermarkHandler : IRequestHandler<DeleteWatermark, Either<Ba
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = await WatermarkMustExist(
dbContext,
request,
cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p));
Option<ChannelWatermark> maybeWatermark = await WatermarkMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeWatermark.Match(
Some: watermark => DoDeletion(dbContext, watermark).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Watermark {request.WatermarkId} does not exist")));
}
private async Task<Unit> DoDeletion(TvContext dbContext, ChannelWatermark watermark)
@@ -40,11 +44,10 @@ public class DeleteWatermarkHandler : IRequestHandler<DeleteWatermark, Either<Ba
return Unit.Default;
}
private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
private static Task<Option<ChannelWatermark>> WatermarkMustExist(
TvContext dbContext,
DeleteWatermark request,
CancellationToken cancellationToken) =>
dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == request.WatermarkId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"Watermark {request.WatermarkId} does not exist"));
.SelectOneAsync(p => p.Id, p => p.Id == request.WatermarkId, cancellationToken);
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -23,8 +24,18 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
Option<ChannelWatermark> maybeWatermark = await WatermarkMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeWatermark.Match(
Some: async watermark =>
{
Validation<BaseError, string> validation = await ValidateName(dbContext, request);
return await validation.Apply((string _) => ApplyUpdateRequest(dbContext, watermark, request));
},
None: () => Task.FromResult<Either<BaseError, UpdateWatermarkResult>>(
new NotFoundError("Watermark does not exist.")));
}
private async Task<UpdateWatermarkResult> ApplyUpdateRequest(
@@ -61,20 +72,12 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
return new UpdateWatermarkResult(p.Id);
}
private static async Task<Validation<BaseError, ChannelWatermark>> Validate(
TvContext dbContext,
UpdateWatermark request,
CancellationToken cancellationToken) =>
(await WatermarkMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((watermark, _) => watermark);
private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
private static Task<Option<ChannelWatermark>> WatermarkMustExist(
TvContext dbContext,
UpdateWatermark updateWatermark,
CancellationToken cancellationToken) =>
dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == updateWatermark.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Watermark does not exist."));
.SelectOneAsync(p => p.Id, p => p.Id == updateWatermark.Id, cancellationToken);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
+20
View File
@@ -9,6 +9,26 @@ internal static class Mapper
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name);
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
new(
watermark.Id,
watermark.Name,
watermark.Mode,
watermark.ImageSource,
watermark.Image,
watermark.OriginalContentType,
watermark.Location,
watermark.Size,
watermark.WidthPercent,
watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent,
watermark.FrequencyMinutes,
watermark.DurationSeconds,
watermark.Opacity,
watermark.OpacityExpression,
watermark.ZIndex,
watermark.PlaceWithinSourceContent);
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Watermarks;
namespace ErsatzTV.Application.Watermarks;
public record GetWatermarkByIdForApi(int Id) : IRequest<Option<WatermarkFullResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks;
public class GetWatermarkByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetWatermarkByIdForApi, Option<WatermarkFullResponseModel>>
{
public async Task<Option<WatermarkFullResponseModel>> Handle(
GetWatermarkByIdForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ChannelWatermarks
.AsNoTracking()
.SelectOneAsync(w => w.Id, w => w.Id == request.Id, cancellationToken)
.MapT(ProjectToFullResponseModel);
}
}
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Api.FFmpegProfiles;
@@ -7,13 +8,17 @@ public record FFmpegFullProfileResponseModel(
int Id,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
string Resolution,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -26,7 +31,9 @@ public record FFmpegFullProfileResponseModel(
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool? DeinterlaceVideo);
bool NormalizeColors,
bool DeinterlaceVideo);
@@ -0,0 +1,23 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetFullResponseModel(
int Id,
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);
@@ -0,0 +1,24 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkFullResponseModel(
int Id,
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);
@@ -28,6 +28,7 @@ public class ApiControllerSecurityTests
typeof(ChannelController),
typeof(CollectionController),
typeof(FFmpegProfileController),
typeof(FillerPresetController),
typeof(LibrariesController),
typeof(LogsController),
typeof(MaintenanceController),
@@ -39,7 +40,8 @@ public class ApiControllerSecurityTests
typeof(SessionController),
typeof(SettingsController),
typeof(SmartCollectionController),
typeof(TroubleshootController)
typeof(TroubleshootController),
typeof(WatermarkController)
];
foreach (Type controllerType in apiControllers)
@@ -188,13 +188,17 @@ public class FFmpegProfileControllerTests
id,
"Default",
1,
true,
true,
HardwareAccelerationKind.None,
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
1,
"HD",
ScalingBehavior.ScaleAndPad,
FilterMode.Software,
FFmpegProfileVideoFormat.H264,
string.Empty,
string.Empty,
@@ -207,9 +211,11 @@ public class FFmpegProfileControllerTests
192,
384,
NormalizeLoudnessMode.Off,
null,
2,
48_000,
false,
true,
false);
private static CreateFFmpegProfileRequest MakeCreateRequest() =>
@@ -1,21 +1,27 @@
using System.Reflection;
using ErsatzTV.Application.Filler;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
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 FillerPresetControllerTests
{
private FillerPresetController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
@@ -23,15 +29,33 @@ public class FillerPresetControllerTests
_controller = new FillerPresetController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(FillerPresetController).GetMethod(nameof(FillerPresetController.GetAll))
?? throw new AssertionException("Missing action GetAll");
private FillerPresetController _controller = null!;
private IMediator _mediator = null!;
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/filler-presets");
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute("GET", "/api/filler-presets");
ShouldHaveActionRoute("GET", "/api/filler-presets/{id:int}");
ShouldHaveActionRoute("POST", "/api/filler-presets");
ShouldHaveActionRoute("PUT", "/api/filler-presets/{id:int}");
ShouldHaveActionRoute("DELETE", "/api/filler-presets/{id:int}");
}
[Test]
public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract()
{
ParameterInfo createRequest = typeof(FillerPresetController)
.GetMethod(nameof(FillerPresetController.Create))!
.GetParameters()
.Single(p => p.Name == "request");
ParameterInfo updateRequest = typeof(FillerPresetController)
.GetMethod(nameof(FillerPresetController.Update))!
.GetParameters()
.Single(p => p.Name == "request");
createRequest.ParameterType.ShouldBe(typeof(CreateFillerPresetRequest));
updateRequest.ParameterType.ShouldBe(typeof(UpdateFillerPresetRequest));
}
[Test]
@@ -51,13 +75,170 @@ public class FillerPresetControllerTests
}
[Test]
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
public async Task GetById_Should_Return_200_For_Some()
{
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
FillerPresetFullResponseModel vm = MakeVm(4);
_mediator.Send(Arg.Any<GetFillerPresetByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FillerPresetFullResponseModel>.Some(vm));
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeEmpty();
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetFillerPresetByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FillerPresetFullResponseModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateFillerPreset>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateFillerPresetResult>(new CreateFillerPresetResult(7)));
FillerPresetFullResponseModel vm = MakeVm(7);
_mediator.Send(Arg.Any<GetFillerPresetByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FillerPresetFullResponseModel>.Some(vm));
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/filler-presets/7");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateFillerPreset>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateFillerPresetResult>(BaseError.New("bad")));
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command()
{
_mediator.Send(Arg.Any<UpdateFillerPreset>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
FillerPresetFullResponseModel vm = MakeVm(8);
_mediator.Send(Arg.Any<GetFillerPresetByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<FillerPresetFullResponseModel>.Some(vm));
IActionResult result = await _controller.Update(8, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateFillerPreset>(c => c.Id == 8),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_422_For_Error()
{
_mediator.Send(Arg.Any<UpdateFillerPreset>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("nope")));
IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteFillerPreset>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteFillerPreset>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
private static void ShouldHaveActionRoute(string httpMethod, string route)
{
bool exists = typeof(FillerPresetController)
.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 FillerPresetFullResponseModel MakeVm(int id) =>
new(
id,
"Intro",
FillerKind.PreRoll,
FillerMode.Count,
null,
3,
null,
true,
CollectionType.Collection,
5,
null,
null,
null,
null,
null,
false);
private static CreateFillerPresetRequest MakeCreateRequest() =>
new(
"Intro",
FillerKind.PreRoll,
FillerMode.Count,
null,
3,
null,
true,
CollectionType.Collection,
5,
null,
null,
null,
null,
null,
false);
private static UpdateFillerPresetRequest MakeUpdateRequest() =>
new(
"Intro",
FillerKind.PreRoll,
FillerMode.Count,
null,
3,
null,
true,
CollectionType.Collection,
5,
null,
null,
null,
null,
null,
false);
}
@@ -174,6 +174,26 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")]
[TestCase("/api/filler-presets/{id}", "get", "404")]
[TestCase("/api/filler-presets", "post", "401")]
[TestCase("/api/filler-presets", "post", "404")]
[TestCase("/api/filler-presets", "post", "422")]
[TestCase("/api/filler-presets/{id}", "put", "401")]
[TestCase("/api/filler-presets/{id}", "put", "404")]
[TestCase("/api/filler-presets/{id}", "put", "422")]
[TestCase("/api/filler-presets/{id}", "delete", "401")]
[TestCase("/api/filler-presets/{id}", "delete", "404")]
[TestCase("/api/filler-presets/{id}", "delete", "422")]
[TestCase("/api/watermarks/{id}", "get", "404")]
[TestCase("/api/watermarks", "post", "401")]
[TestCase("/api/watermarks", "post", "404")]
[TestCase("/api/watermarks", "post", "422")]
[TestCase("/api/watermarks/{id}", "put", "401")]
[TestCase("/api/watermarks/{id}", "put", "404")]
[TestCase("/api/watermarks/{id}", "put", "422")]
[TestCase("/api/watermarks/{id}", "delete", "401")]
[TestCase("/api/watermarks/{id}", "delete", "404")]
[TestCase("/api/watermarks/{id}", "delete", "422")]
[TestCase("/api/settings/ffmpeg", "put", "401")]
[TestCase("/api/settings/ffmpeg", "put", "422")]
[TestCase("/api/settings/playout", "put", "401")]
@@ -1,21 +1,27 @@
using System.Reflection;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.FFmpeg.State;
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 WatermarkControllerTests
{
private WatermarkController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
@@ -23,15 +29,33 @@ public class WatermarkControllerTests
_controller = new WatermarkController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(WatermarkController).GetMethod(nameof(WatermarkController.GetAll))
?? throw new AssertionException("Missing action GetAll");
private WatermarkController _controller = null!;
private IMediator _mediator = null!;
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/watermarks");
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute("GET", "/api/watermarks");
ShouldHaveActionRoute("GET", "/api/watermarks/{id:int}");
ShouldHaveActionRoute("POST", "/api/watermarks");
ShouldHaveActionRoute("PUT", "/api/watermarks/{id:int}");
ShouldHaveActionRoute("DELETE", "/api/watermarks/{id:int}");
}
[Test]
public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract()
{
ParameterInfo createRequest = typeof(WatermarkController)
.GetMethod(nameof(WatermarkController.Create))!
.GetParameters()
.Single(p => p.Name == "request");
ParameterInfo updateRequest = typeof(WatermarkController)
.GetMethod(nameof(WatermarkController.Update))!
.GetParameters()
.Single(p => p.Name == "request");
createRequest.ParameterType.ShouldBe(typeof(CreateWatermarkRequest));
updateRequest.ParameterType.ShouldBe(typeof(UpdateWatermarkRequest));
}
[Test]
@@ -51,13 +75,173 @@ public class WatermarkControllerTests
}
[Test]
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
public async Task GetById_Should_Return_200_For_Some()
{
_mediator.Send(Arg.Any<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
WatermarkFullResponseModel vm = MakeVm(4);
_mediator.Send(Arg.Any<GetWatermarkByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<WatermarkFullResponseModel>.Some(vm));
List<WatermarkResponseModel> result = await _controller.GetAll(CancellationToken.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeEmpty();
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetWatermarkByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<WatermarkFullResponseModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateWatermark>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateWatermarkResult>(new CreateWatermarkResult(7)));
WatermarkFullResponseModel vm = MakeVm(7);
_mediator.Send(Arg.Any<GetWatermarkByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<WatermarkFullResponseModel>.Some(vm));
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/watermarks/7");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateWatermark>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateWatermarkResult>(BaseError.New("bad")));
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id_To_Command()
{
_mediator.Send(Arg.Any<UpdateWatermark>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, UpdateWatermarkResult>(new UpdateWatermarkResult(8)));
WatermarkFullResponseModel vm = MakeVm(8);
_mediator.Send(Arg.Any<GetWatermarkByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<WatermarkFullResponseModel>.Some(vm));
IActionResult result = await _controller.Update(8, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateWatermark>(c => c.Id == 8),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateWatermark>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, UpdateWatermarkResult>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteWatermark>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteWatermark>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
private static void ShouldHaveActionRoute(string httpMethod, string route)
{
bool exists = typeof(WatermarkController)
.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 WatermarkFullResponseModel MakeVm(int id) =>
new(
id,
"Corner Logo",
ChannelWatermarkMode.Permanent,
ChannelWatermarkImageSource.Custom,
"abc.png",
"image/png",
WatermarkLocation.BottomRight,
WatermarkSize.Scaled,
15,
5,
5,
15,
10,
80,
null,
0,
false);
private static CreateWatermarkRequest MakeCreateRequest() =>
new(
"Corner Logo",
ChannelWatermarkMode.Permanent,
ChannelWatermarkImageSource.Custom,
"abc.png",
"image/png",
WatermarkLocation.BottomRight,
WatermarkSize.Scaled,
15,
5,
5,
15,
10,
80,
null,
0,
false);
private static UpdateWatermarkRequest MakeUpdateRequest() =>
new(
"Corner Logo",
ChannelWatermarkMode.Permanent,
ChannelWatermarkImageSource.Custom,
"abc.png",
"image/png",
WatermarkLocation.BottomRight,
WatermarkSize.Scaled,
15,
5,
5,
15,
10,
80,
null,
0,
false);
}
@@ -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
+2 -2
View File
@@ -2712,7 +2712,7 @@ describe('Settings screen (#93)', () => {
expect(screen.queryByRole('button', { name: 'Delete 1920×1080' })).not.toBeInTheDocument();
});
it('renders FFmpeg profiles and media sources as read-only cards with a Legacy UI callout', async () => {
it('renders FFmpeg profiles read-only with a link to the FFmpeg Profiles editor, and media sources', async () => {
mockDashboardApi({
ffmpegProfiles: [ffmpegProfile({ id: 1, name: '1080p H.264' }), ffmpegProfile({ id: 2, name: '720p H.264' })],
mediaSources: [mediaSource({ id: 30, kind: 'Local', name: 'Local' })]
@@ -2722,7 +2722,7 @@ describe('Settings screen (#93)', () => {
expect((await screen.findAllByText('1080p H.264')).length).toBeGreaterThan(0);
expect(screen.getAllByText('720p H.264').length).toBeGreaterThan(0);
expect(screen.getAllByText('Legacy UI').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: /Manage FFmpeg profiles/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
expect(await screen.findByText('Local')).toBeInTheDocument();
+64 -2
View File
@@ -46,7 +46,9 @@ import {
Server,
Settings,
Shuffle,
SlidersHorizontal,
Sparkles,
Stamp,
Stethoscope,
Timer,
Trash2,
@@ -57,9 +59,12 @@ import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg';
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
import { ChannelEditScreen } from './screens/ChannelEditScreen';
import { CollectionsScreen } from './screens/CollectionsScreen';
import { FFmpegProfilesScreen } from './screens/FFmpegProfilesScreen';
import { FillerPresetsScreen } from './screens/FillerPresetsScreen';
import { LogsScreen } from './screens/LogsScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
import { WatermarksScreen } from './screens/WatermarksScreen';
import { navigateToPath } from './routing';
import {
Badge,
@@ -134,7 +139,10 @@ type ScreenId =
| 'schedules'
| 'playouts'
| 'collections'
| 'fillerPresets'
| 'libraries'
| 'ffmpegProfiles'
| 'watermarks'
| 'settings'
| 'logs'
| 'troubleshooting';
@@ -249,6 +257,18 @@ const routes: ScreenRoute[] = [
primaryAction: 'Add Collection',
placeholder: 'Collections workspace'
},
{
id: 'fillerPresets',
path: '/app/filler-presets',
label: 'Filler Presets',
title: 'Filler Presets',
kicker: 'Media',
description: 'Pre/mid/post-roll, tail and fallback filler presets used by schedules.',
icon: <Film aria-hidden="true" size={16} />,
primaryAction: 'Add Filler Preset',
placeholder: 'Filler presets workspace',
allowSubPaths: true
},
{
id: 'libraries',
path: '/app/libraries',
@@ -260,6 +280,30 @@ const routes: ScreenRoute[] = [
primaryAction: 'Scan',
placeholder: 'Libraries workspace'
},
{
id: 'ffmpegProfiles',
path: '/app/ffmpeg-profiles',
label: 'FFmpeg Profiles',
title: 'FFmpeg Profiles',
kicker: 'System',
description: 'Transcoding profiles: resolution, video/audio formats, hardware acceleration.',
icon: <SlidersHorizontal aria-hidden="true" size={16} />,
primaryAction: 'Add Profile',
placeholder: 'FFmpeg profiles workspace',
allowSubPaths: true
},
{
id: 'watermarks',
path: '/app/watermarks',
label: 'Watermarks',
title: 'Watermarks',
kicker: 'System',
description: 'Channel watermark overlays: image, position, size and opacity.',
icon: <Stamp aria-hidden="true" size={16} />,
primaryAction: 'Add Watermark',
placeholder: 'Watermarks workspace',
allowSubPaths: true
},
{
id: 'settings',
path: '/app/settings',
@@ -306,8 +350,14 @@ const primaryNavIds: ScreenId[] = [
'schedules',
'playouts'
];
const mediaNavIds: ScreenId[] = ['collections', 'libraries'];
const systemNavIds: ScreenId[] = ['settings', 'logs', 'troubleshooting'];
const mediaNavIds: ScreenId[] = ['collections', 'fillerPresets', 'libraries'];
const systemNavIds: ScreenId[] = [
'settings',
'logs',
'troubleshooting',
'ffmpegProfiles',
'watermarks'
];
function normalizePath(pathname: string): string {
return pathname.replace(/\/+$/, '') || '/app';
@@ -2914,6 +2964,18 @@ function ScreenContent({
return <CollectionsScreen />;
}
if (route.id === 'fillerPresets') {
return <FillerPresetsScreen />;
}
if (route.id === 'ffmpegProfiles') {
return <FFmpegProfilesScreen />;
}
if (route.id === 'watermarks') {
return <WatermarksScreen />;
}
if (route.id === 'logs') {
return <LogsScreen />;
}
+105
View File
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createFFmpegProfile,
deleteFFmpegProfile,
getFFmpegProfile,
getHardwareAccelerationKinds,
updateFFmpegProfile,
type CreateFFmpegProfileRequest
} from './ffmpegProfiles';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
const sampleRequest: CreateFFmpegProfileRequest = {
allowBFrames: false,
audioBitrate: 192,
audioBufferSize: 384,
audioChannels: 2,
audioFormat: 'Aac',
audioSampleRate: 48,
bitDepth: 'EightBit',
deinterlaceVideo: false,
hardwareAcceleration: 'None',
name: 'New',
normalizeAudio: true,
normalizeColors: true,
normalizeFramerate: false,
normalizeLoudnessMode: 'Off',
normalizeVideo: true,
padMode: 'Software',
qsvExtraHardwareFrames: null,
resolutionId: 1,
scalingBehavior: 'ScaleAndPad',
tonemapAlgorithm: 'Linear',
targetLoudness: null,
threadCount: 0,
vaapiDevice: '',
vaapiDisplay: '',
vaapiDriver: 'Default',
videoBitrate: 2000,
videoBufferSize: 4000,
videoFormat: 'H264',
videoPreset: '',
videoProfile: 'high'
};
describe('ffmpeg profiles api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('getFFmpegProfile fetches a profile by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, name: 'Default' }));
await expect(getFFmpegProfile(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/ffmpeg/profiles/4', expect.objectContaining({ method: 'GET' }));
});
it('createFFmpegProfile POSTs the request body', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 7, name: 'New' }, 201));
await expect(createFFmpegProfile(sampleRequest)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/ffmpeg/profiles');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).resolutionId).toBe(1);
});
it('updateFFmpegProfile PUTs to the id route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3, name: 'Edited' }));
await updateFFmpegProfile(3, sampleRequest);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/ffmpeg/profiles/3');
expect(init).toMatchObject({ method: 'PUT' });
});
it('deleteFFmpegProfile issues a DELETE and resolves on 204', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteFFmpegProfile(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/ffmpeg/profiles/9', expect.objectContaining({ method: 'DELETE' }));
});
it('getHardwareAccelerationKinds fetches the lookup', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(['None', 'Nvenc']));
await expect(getHardwareAccelerationKinds()).resolves.toEqual(['None', 'Nvenc']);
expect(fetchMock).toHaveBeenCalledWith(
'/api/ffmpeg/hardware-acceleration-kinds',
expect.objectContaining({ method: 'GET' })
);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
// The list endpoint (GET /api/ffmpeg/profiles) is already provided by pickers.ts as
// getFFmpegProfiles() returning FFmpegProfile (= FFmpegFullProfileResponseModel). This
// module adds the by-id read, the mutations, and the hardware-acceleration lookup.
export type FFmpegProfileDetail = components['schemas']['FFmpegFullProfileResponseModel'];
export type CreateFFmpegProfileRequest = components['schemas']['CreateFFmpegProfileRequest'];
export type UpdateFFmpegProfileRequest = components['schemas']['UpdateFFmpegProfileRequest'];
export type HardwareAccelerationKind = components['schemas']['HardwareAccelerationKind'];
export function getFFmpegProfile(id: number): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>(`/api/ffmpeg/profiles/${id}`);
}
export function createFFmpegProfile(body: CreateFFmpegProfileRequest): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>('/api/ffmpeg/profiles', { body, method: 'POST' });
}
export function updateFFmpegProfile(id: number, body: UpdateFFmpegProfileRequest): Promise<FFmpegProfileDetail> {
return request<FFmpegProfileDetail>(`/api/ffmpeg/profiles/${id}`, { body, method: 'PUT' });
}
export function deleteFFmpegProfile(id: number): Promise<void> {
return request<void>(`/api/ffmpeg/profiles/${id}`, { method: 'DELETE' });
}
export function getHardwareAccelerationKinds(): Promise<HardwareAccelerationKind[]> {
return request<HardwareAccelerationKind[]>('/api/ffmpeg/hardware-acceleration-kinds');
}
export function messageFromFFmpegProfileError(error: unknown, fallback = 'Unable to load FFmpeg profiles'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+79
View File
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createFillerPreset,
deleteFillerPreset,
getFillerPreset,
updateFillerPreset,
type CreateFillerPresetRequest
} from './fillerPresets';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
const sampleRequest: CreateFillerPresetRequest = {
allowWatermarks: true,
collectionId: 5,
collectionType: 'Collection',
count: 3,
duration: null,
expression: null,
fillerKind: 'PreRoll',
fillerMode: 'Count',
mediaItemId: null,
multiCollectionId: null,
name: 'Intro',
padToNearestMinute: null,
playlistId: null,
smartCollectionId: null,
useChaptersAsMediaItems: false
};
describe('filler presets api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('getFillerPreset fetches a preset by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, name: 'Intro' }));
await expect(getFillerPreset(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/filler-presets/4', expect.objectContaining({ method: 'GET' }));
});
it('createFillerPreset POSTs the request body', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 7, name: 'Intro' }, 201));
await expect(createFillerPreset(sampleRequest)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/filler-presets');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).fillerKind).toBe('PreRoll');
});
it('updateFillerPreset PUTs to the id route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3, name: 'Intro' }));
await updateFillerPreset(3, sampleRequest);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/filler-presets/3');
expect(init).toMatchObject({ method: 'PUT' });
});
it('deleteFillerPreset issues a DELETE and resolves on 204', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteFillerPreset(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/filler-presets/9', expect.objectContaining({ method: 'DELETE' }));
});
});
+38
View File
@@ -0,0 +1,38 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
// The list endpoint (GET /api/filler-presets) is picker-grade (id + name) and is already
// provided by pickers.ts as getFillerPresets(). This module adds the by-id read (full
// model) and the mutations.
export type FillerPresetDetail = components['schemas']['FillerPresetFullResponseModel'];
export type CreateFillerPresetRequest = components['schemas']['CreateFillerPresetRequest'];
export type UpdateFillerPresetRequest = components['schemas']['UpdateFillerPresetRequest'];
export function getFillerPreset(id: number): Promise<FillerPresetDetail> {
return request<FillerPresetDetail>(`/api/filler-presets/${id}`);
}
export function createFillerPreset(body: CreateFillerPresetRequest): Promise<FillerPresetDetail> {
return request<FillerPresetDetail>('/api/filler-presets', { body, method: 'POST' });
}
export function updateFillerPreset(id: number, body: UpdateFillerPresetRequest): Promise<FillerPresetDetail> {
return request<FillerPresetDetail>(`/api/filler-presets/${id}`, { body, method: 'PUT' });
}
export function deleteFillerPreset(id: number): Promise<void> {
return request<void>(`/api/filler-presets/${id}`, { method: 'DELETE' });
}
export function messageFromFillerPresetError(error: unknown, fallback = 'Unable to load filler presets'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+122 -9
View File
@@ -303,6 +303,23 @@ export interface components {
"normalizeFramerate": boolean;
"normalizeColors": boolean;
"deinterlaceVideo": boolean;
};
"CreateFillerPresetRequest": {
"name": string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlistId": null | number;
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"CreatePlayoutRequest": {
"channelId": number;
@@ -323,21 +340,43 @@ export interface components {
"CreateSmartCollectionRequest": {
"name": null | string;
"query": null | string;
};
"CreateWatermarkRequest": {
"name": string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"image": null | string;
"imageContentType": null | string;
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"opacityExpression": null | string;
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"FFmpegFullProfileResponseModel": {
"id": number;
"name": null | string;
"name": string;
"threadCount": number;
"normalizeAudio": boolean;
"normalizeVideo": boolean;
"hardwareAcceleration": components["schemas"]["HardwareAccelerationKind"];
"vaapiDisplay": null | string;
"vaapiDisplay": string;
"vaapiDriver": components["schemas"]["VaapiDriver"];
"vaapiDevice": null | string;
"vaapiDevice": string;
"qsvExtraHardwareFrames": null | number;
"resolution": null | string;
"resolutionId": number;
"resolution": string;
"scalingBehavior": components["schemas"]["ScalingBehavior"];
"padMode": components["schemas"]["FilterMode"];
"videoFormat": components["schemas"]["FFmpegProfileVideoFormat"];
"videoProfile": null | string;
"videoPreset": null | string;
"videoProfile": string;
"videoPreset": string;
"allowBFrames": boolean;
"bitDepth": components["schemas"]["FFmpegProfileBitDepth"];
"videoBitrate": number;
@@ -347,10 +386,12 @@ export interface components {
"audioBitrate": number;
"audioBufferSize": number;
"normalizeLoudnessMode": components["schemas"]["NormalizeLoudnessMode"];
"targetLoudness": null | number;
"audioChannels": number;
"audioSampleRate": number;
"normalizeFramerate": boolean;
"deinterlaceVideo": null | boolean;
"normalizeColors": boolean;
"deinterlaceVideo": boolean;
};
"FFmpegProfileAudioFormat": "None" | "Aac" | "Ac3" | "AacLatm" | "Copy";
"FFmpegProfileBitDepth": "EightBit" | "TenBit";
@@ -375,6 +416,24 @@ export interface components {
};
"FillerKind": "None" | "PreRoll" | "MidRoll" | "PostRoll" | "Tail" | "Fallback" | "GuideMode" | "DecoDefault";
"FillerMode": "None" | "Duration" | "Count" | "Pad" | "RandomCount";
"FillerPresetFullResponseModel": {
"id": number;
"name": string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlistId": null | number;
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"FillerPresetResponseModel": {
"id": number;
"name": null | string;
@@ -848,6 +907,23 @@ export interface components {
"initialSegmentCount": number;
"hlsDirectOutputFormat": components["schemas"]["OutputFormatKind"];
"defaultMpegTsScript": null | string;
};
"UpdateFillerPresetRequest": {
"name": string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlistId": null | number;
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"UpdateHdhrSettingsRequest": {
"tunerCount": number;
@@ -883,6 +959,24 @@ export interface components {
"UpdateUiSettingsRequest": {
"isDarkMode": boolean;
"language": null | string;
};
"UpdateWatermarkRequest": {
"name": string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"image": null | string;
"imageContentType": null | string;
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"opacityExpression": null | string;
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"UpdateXmltvSettingsRequest": {
"daysToBuild": number;
@@ -890,12 +984,31 @@ export interface components {
"blockBehavior": components["schemas"]["XmltvBlockBehavior"];
};
"VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau";
"WatermarkLocation": number;
"WatermarkFullResponseModel": {
"id": number;
"name": string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"image": null | string;
"imageContentType": null | string;
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"opacityExpression": null | string;
"zIndex": number;
"placeWithinSourceContent": boolean;
};
"WatermarkLocation": "BottomRight" | "BottomLeft" | "TopRight" | "TopLeft" | "TopMiddle" | "RightMiddle" | "BottomMiddle" | "LeftMiddle" | "MiddleCenter";
"WatermarkResponseModel": {
"id": number;
"name": null | string;
};
"WatermarkSize": number;
"WatermarkSize": "Scaled" | "ActualSize";
"WatermarkViewModel": {
"id": number;
"image": components["schemas"]["ArtworkContentTypeModel"];
+3
View File
@@ -5,6 +5,8 @@ export * from './channelTemplates';
export * from './client';
export * from './collections';
export * from './dashboard';
export * from './ffmpegProfiles';
export * from './fillerPresets';
export * from './guide';
export * from './libraries';
export * from './libraryBrowse';
@@ -15,3 +17,4 @@ export * from './schedules';
export * from './settings';
export * from './troubleshoot';
export * from './useChannelsQuery';
export * from './watermarks';
+86
View File
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createWatermark,
deleteWatermark,
getWatermark,
updateWatermark,
watermarkImageUrl,
type CreateWatermarkRequest
} from './watermarks';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
const sampleRequest: CreateWatermarkRequest = {
durationSeconds: 15,
frequencyMinutes: 15,
horizontalMargin: 5,
image: 'abc.png',
imageContentType: 'image/png',
imageSource: 'Custom',
location: 'BottomRight',
mode: 'Permanent',
name: 'Corner Logo',
opacity: 100,
opacityExpression: null,
placeWithinSourceContent: false,
size: 'Scaled',
verticalMargin: 5,
width: 15,
zIndex: 0
};
describe('watermarks api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('getWatermark fetches a watermark by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 4, name: 'Corner Logo' }));
await expect(getWatermark(4)).resolves.toMatchObject({ id: 4 });
expect(fetchMock).toHaveBeenCalledWith('/api/watermarks/4', expect.objectContaining({ method: 'GET' }));
});
it('createWatermark POSTs the request body', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 7, name: 'Corner Logo' }, 201));
await expect(createWatermark(sampleRequest)).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/watermarks');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).mode).toBe('Permanent');
});
it('updateWatermark PUTs to the id route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3, name: 'Corner Logo' }));
await updateWatermark(3, sampleRequest);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/watermarks/3');
expect(init).toMatchObject({ method: 'PUT' });
});
it('deleteWatermark issues a DELETE and resolves on 204', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteWatermark(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/watermarks/9', expect.objectContaining({ method: 'DELETE' }));
});
it('watermarkImageUrl encodes the content type query', () => {
expect(watermarkImageUrl('abc.png', 'image/png')).toBe('/artwork/watermarks/abc.png?contentType=image%2Fpng');
expect(watermarkImageUrl('abc.png')).toBe('/artwork/watermarks/abc.png');
});
});
+47
View File
@@ -0,0 +1,47 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
// The list endpoint (GET /api/watermarks) is picker-grade (id + name) and is already
// provided by pickers.ts as getWatermarks(). This module adds the by-id read (full model)
// and the mutations. Image upload is handled by artwork.ts (uploadArtwork(file,'watermark')).
export type WatermarkDetail = components['schemas']['WatermarkFullResponseModel'];
export type CreateWatermarkRequest = components['schemas']['CreateWatermarkRequest'];
export type UpdateWatermarkRequest = components['schemas']['UpdateWatermarkRequest'];
export function getWatermark(id: number): Promise<WatermarkDetail> {
return request<WatermarkDetail>(`/api/watermarks/${id}`);
}
export function createWatermark(body: CreateWatermarkRequest): Promise<WatermarkDetail> {
return request<WatermarkDetail>('/api/watermarks', { body, method: 'POST' });
}
export function updateWatermark(id: number, body: UpdateWatermarkRequest): Promise<WatermarkDetail> {
return request<WatermarkDetail>(`/api/watermarks/${id}`, { body, method: 'PUT' });
}
export function deleteWatermark(id: number): Promise<void> {
return request<void>(`/api/watermarks/${id}`, { method: 'DELETE' });
}
// Preview URL for a custom watermark image. Mirrors the Blazor editor
// (WatermarkEditor.razor) which renders `artwork/watermarks/{path}?contentType=...` via
// ArtworkContentTypeModel.UrlWithContentType.
export function watermarkImageUrl(path: string, contentType?: null | string): string {
return contentType
? `/artwork/watermarks/${path}?contentType=${encodeURIComponent(contentType)}`
: `/artwork/watermarks/${path}`;
}
export function messageFromWatermarkError(error: unknown, fallback = 'Unable to load watermarks'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+846
View File
@@ -0,0 +1,846 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Copy, Plus, SlidersHorizontal, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import {
createFFmpegProfile,
deleteFFmpegProfile,
getFFmpegProfile,
getFFmpegProfiles,
getHardwareAccelerationKinds,
getResolutions,
messageFromFFmpegProfileError,
updateFFmpegProfile,
type CreateFFmpegProfileRequest,
type FFmpegProfile,
type HardwareAccelerationKind,
type Resolution
} from '../api';
const BASE_PATH = '/app/ffmpeg-profiles';
type Draft = CreateFFmpegProfileRequest;
/* ---------- enum option lists (mirror ErsatzTV/Pages/FFmpegEditor.razor) ---------- */
const SCALING_OPTIONS = [
{ label: 'Scale and Pad', value: 'ScaleAndPad' },
{ label: 'Stretch', value: 'Stretch' },
{ label: 'Crop', value: 'Crop' }
];
const PAD_MODE_OPTIONS = [
{ label: 'Hardware If Possible', value: 'HardwareIfPossible' },
{ label: 'Software', value: 'Software' }
];
const BIT_DEPTH_OPTIONS = [
{ label: '8-bit', value: 'EightBit' },
{ label: '10-bit', value: 'TenBit' }
];
const AUDIO_FORMAT_OPTIONS = [
{ label: 'aac', value: 'Aac' },
{ label: 'ac3', value: 'Ac3' },
{ label: 'aac (latm)', value: 'AacLatm' }
];
const LOUDNESS_OPTIONS = [
{ label: 'Off', value: 'Off' },
{ label: 'loudnorm', value: 'LoudNorm' }
];
const TONEMAP_OPTIONS = ['Linear', 'Clip', 'Gamma', 'Reinhard', 'Mobius', 'Hable'];
const VAAPI_DRIVER_OPTIONS = ['Default', 'iHD', 'i965', 'RadeonSI', 'Nouveau'];
// av1 is only offered for Nvenc / Vaapi / Qsv (see FFmpegEditor.razor)
function videoFormatOptions(hwaccel: HardwareAccelerationKind): Array<{ label: string; value: string }> {
const base = [
{ label: 'h264', value: 'H264' },
{ label: 'hevc', value: 'Hevc' },
{ label: 'mpeg-2', value: 'Mpeg2Video' }
];
if (hwaccel === 'Nvenc' || hwaccel === 'Vaapi' || hwaccel === 'Qsv') {
base.push({ label: 'av1', value: 'Av1' });
}
return base;
}
// main / high plus high444p (nvenc) or high10 (everything else), matching the Blazor select.
function videoProfileOptions(hwaccel: HardwareAccelerationKind): Array<{ label: string; value: string }> {
const base = [
{ label: 'main', value: 'main' },
{ label: 'high', value: 'high' }
];
base.push(hwaccel === 'Nvenc' ? { label: 'high444p', value: 'high444p' } : { label: 'high10', value: 'high10' });
return base;
}
// Ported from ErsatzTV.Core/FFmpeg/FFmpegLibraryHelper.cs -> PresetsForFFmpegProfile and
// ErsatzTV.FFmpeg/Preset/AvailablePresets.cs -> ForAccelAndFormat. Keep in sync with those.
function presetsFor(
hwaccel: HardwareAccelerationKind,
videoFormat: string,
bitDepth: string
): string[] {
const bits = bitDepth === 'TenBit' ? 10 : 8;
// 10-bit h264 always uses libx264
if (videoFormat === 'H264' && bits === 10) {
return ['veryfast'];
}
if (hwaccel === 'Nvenc' && videoFormat === 'H264' && bits === 8) {
return ['llhp', 'llhq'];
}
if (hwaccel === 'Nvenc' && videoFormat === 'Hevc') {
return ['llhp', 'llhq'];
}
if (hwaccel === 'Qsv' && (videoFormat === 'H264' || videoFormat === 'Hevc')) {
return ['veryfast'];
}
if (hwaccel === 'None' && (videoFormat === 'H264' || videoFormat === 'Hevc')) {
return ['veryfast'];
}
return [];
}
function hwaccelSupportsH264Profile(hwaccel: HardwareAccelerationKind): boolean {
return (
hwaccel === 'None' ||
hwaccel === 'Nvenc' ||
hwaccel === 'Qsv' ||
hwaccel === 'Vaapi' ||
hwaccel === 'VideoToolbox'
);
}
const ALLOWED_FORMATS: Partial<Record<HardwareAccelerationKind, string[]>> = {
Amf: ['H264', 'Hevc'],
Nvenc: ['H264', 'Hevc', 'Av1'],
Qsv: ['H264', 'Hevc', 'Mpeg2Video', 'Av1'],
Rkmpp: ['H264', 'Hevc'],
V4l2m2m: ['H264', 'Hevc'],
Vaapi: ['H264', 'Hevc', 'Mpeg2Video', 'Av1'],
VideoToolbox: ['H264', 'Hevc']
};
// Mirrors FFmpegProfileEditViewModelValidator. Returns the first blocking message, or null.
function validate(draft: Draft): null | string {
if (!draft.name?.trim()) {
return 'Name is required';
}
if (draft.threadCount < 0) {
return 'Thread count must be 0 or greater';
}
if (draft.normalizeVideo) {
if (draft.videoBitrate <= 0) {
return 'Video bitrate must be greater than 0';
}
if (draft.videoBufferSize <= 0) {
return 'Video buffer size must be greater than 0';
}
const allowed = ALLOWED_FORMATS[draft.hardwareAcceleration];
if (allowed && !allowed.includes(draft.videoFormat)) {
return `${draft.hardwareAcceleration} supports formats (${allowed.join(', ').toLowerCase()})`;
}
if (draft.videoFormat === 'Mpeg2Video' && draft.bitDepth === 'TenBit') {
return 'Mpeg2Video does not support 10-bit content';
}
if (draft.videoFormat === 'H264' && draft.bitDepth === 'TenBit') {
if (draft.hardwareAcceleration !== 'Nvenc' && draft.videoProfile !== 'high10') {
return 'VideoProfile must be high10 with 10-bit h264';
}
if (draft.hardwareAcceleration === 'Nvenc' && draft.videoProfile !== 'high444p') {
return 'VideoProfile must be high444p with NVIDIA 10-bit h264';
}
}
if (draft.videoFormat === 'H264' && draft.bitDepth === 'EightBit' && draft.videoProfile === 'high10') {
return 'VideoProfile cannot be high10 with 8-bit h264';
}
}
if (draft.normalizeAudio) {
if (draft.audioBitrate <= 0) {
return 'Audio bitrate must be greater than 0';
}
if (draft.audioChannels <= 0) {
return 'Audio channels must be greater than 0';
}
}
return null;
}
function defaultDraft(resolutionId: number): Draft {
return {
allowBFrames: false,
audioBitrate: 192,
audioBufferSize: 384,
audioChannels: 2,
audioFormat: 'Aac',
audioSampleRate: 48,
bitDepth: 'EightBit',
deinterlaceVideo: true,
hardwareAcceleration: 'None',
name: '',
normalizeAudio: true,
normalizeColors: true,
normalizeFramerate: false,
normalizeLoudnessMode: 'Off',
normalizeVideo: true,
padMode: 'Software',
qsvExtraHardwareFrames: 64,
resolutionId,
scalingBehavior: 'ScaleAndPad',
targetLoudness: null,
threadCount: 0,
tonemapAlgorithm: 'Linear',
vaapiDevice: '/dev/dri/renderD128',
vaapiDisplay: 'drm',
vaapiDriver: 'Default',
videoBitrate: 2000,
videoBufferSize: 4000,
videoFormat: 'H264',
videoPreset: '',
videoProfile: 'high'
};
}
// A full profile response carries every request field (round-trip complete) plus id +
// resolution name; drop those two to get the mutation body.
function draftFromProfile(profile: FFmpegProfile): Draft {
return {
allowBFrames: profile.allowBFrames,
audioBitrate: profile.audioBitrate,
audioBufferSize: profile.audioBufferSize,
audioChannels: profile.audioChannels,
audioFormat: profile.audioFormat,
audioSampleRate: profile.audioSampleRate,
bitDepth: profile.bitDepth,
deinterlaceVideo: profile.deinterlaceVideo,
hardwareAcceleration: profile.hardwareAcceleration,
name: profile.name,
normalizeAudio: profile.normalizeAudio,
normalizeColors: profile.normalizeColors,
normalizeFramerate: profile.normalizeFramerate,
normalizeLoudnessMode: profile.normalizeLoudnessMode,
normalizeVideo: profile.normalizeVideo,
padMode: profile.padMode,
qsvExtraHardwareFrames: profile.qsvExtraHardwareFrames,
resolutionId: profile.resolutionId,
scalingBehavior: profile.scalingBehavior,
targetLoudness: profile.targetLoudness,
threadCount: profile.threadCount,
tonemapAlgorithm: profile.tonemapAlgorithm,
vaapiDevice: profile.vaapiDevice,
vaapiDisplay: profile.vaapiDisplay,
vaapiDriver: profile.vaapiDriver,
videoBitrate: profile.videoBitrate,
videoBufferSize: profile.videoBufferSize,
videoFormat: profile.videoFormat,
videoPreset: profile.videoPreset,
videoProfile: profile.videoProfile
};
}
/* ---------- form row helper (mirrors ChannelEditScreen) ---------- */
function Row({
children,
control = 320,
first = false,
help,
label
}: {
children: ReactNode;
control?: number;
first?: boolean;
help?: string;
label: string;
}) {
return (
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
<div className="ctv-settings-row-main">
<div className="ctv-settings-row-label">{label}</div>
{help && <div className="ctv-settings-row-help">{help}</div>}
</div>
<div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>{children}</div>
</div>
);
}
function NumberField({
disabled,
onChange,
unit,
value
}: {
disabled?: boolean;
onChange: (next: number) => void;
unit?: string;
value: number;
}) {
return (
<Input
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
size="sm"
trailing={unit}
type="number"
value={String(value)}
/>
);
}
/* ---------- routing ---------- */
type Mode = { kind: 'add'; from: null | number } | { kind: 'edit'; id: number } | { kind: 'list' };
function modeFromPath(pathname: string): Mode {
const normalized = pathname.replace(/\/+$/, '');
if (normalized === BASE_PATH || !normalized.startsWith(`${BASE_PATH}/`)) {
return { kind: 'list' };
}
const suffix = normalized.slice(BASE_PATH.length + 1).split('/')[0];
if (suffix === 'add') {
const from = new URLSearchParams(window.location.search).get('from');
return { from: from ? Number(from) : null, kind: 'add' };
}
const id = Number(suffix);
return Number.isInteger(id) && id > 0 ? { id, kind: 'edit' } : { kind: 'list' };
}
/* ---------- screen ---------- */
export function FFmpegProfilesScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname + window.location.search);
useEffect(() => {
const onPop = () => setPathname(window.location.pathname + window.location.search);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const mode = modeFromPath(window.location.pathname);
if (mode.kind === 'list') {
return <ProfileList key={pathname} />;
}
return <ProfileEditor key={pathname} mode={mode} />;
}
/* ---------- list ---------- */
function ProfileList() {
const [profiles, setProfiles] = useState<FFmpegProfile[] | null>(null);
const [error, setError] = useState<null | string>(null);
const [deleteTarget, setDeleteTarget] = useState<FFmpegProfile | null>(null);
const [busy, setBusy] = useState(false);
const activeRef = useRef(true);
const load = useCallback(() => {
getFFmpegProfiles()
.then((data) => {
if (activeRef.current) {
setProfiles(data);
setError(null);
}
})
.catch((loadError: unknown) => {
if (activeRef.current) {
setError(messageFromFFmpegProfileError(loadError));
}
});
}, []);
useEffect(() => {
activeRef.current = true;
load();
return () => {
activeRef.current = false;
};
}, [load]);
const confirmDelete = async () => {
if (!deleteTarget) {
return;
}
setBusy(true);
try {
await deleteFFmpegProfile(deleteTarget.id);
setDeleteTarget(null);
load();
} catch (deleteError) {
setError(messageFromFFmpegProfileError(deleteError, 'Unable to delete profile'));
} finally {
setBusy(false);
}
};
if (error && !profiles) {
return (
<div className="ctv-collections">
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={load} size="sm" variant="secondary">
Retry
</Button>
</div>
</div>
);
}
if (!profiles) {
return (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading FFmpeg profiles…</span>
</div>
);
}
const sorted = [...profiles].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Badge tone="neutral">{profiles.length} profiles</Badge>
<span className="ctv-channels-spacer" />
<Button
onClick={() => navigateToPath(`${BASE_PATH}/add`)}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
New profile
</Button>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
)}
<Card padded={false}>
{sorted.length === 0 ? (
<div className="ctv-collections-empty">No FFmpeg profiles yet.</div>
) : (
sorted.map((profile, index) => (
<div
className="ctv-settings-flush-row"
key={profile.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<SlidersHorizontal aria-hidden="true" color="var(--ctv-accent)" size={15} />
<button
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
onClick={() => navigateToPath(`${BASE_PATH}/${profile.id}`)}
type="button"
>
{profile.name}
</button>
<Badge tone="neutral">{profile.resolution}</Badge>
<Badge tone="neutral">{`${profile.videoFormat.toLowerCase()} / ${profile.audioFormat.toLowerCase()}`}</Badge>
<IconButton
onClick={() => navigateToPath(`${BASE_PATH}/add?from=${profile.id}`)}
size="sm"
title={`Copy ${profile.name}`}
variant="ghost"
>
<Copy aria-hidden="true" size={14} />
</IconButton>
<IconButton onClick={() => setDeleteTarget(profile)} size="sm" title="Delete" variant="ghost">
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
<ConfirmDialog
busy={busy}
confirmLabel="Delete"
message={deleteTarget ? `Delete "${deleteTarget.name}"? This cannot be undone.` : ''}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
open={deleteTarget !== null}
title="Delete FFmpeg profile"
tone="danger"
/>
</div>
);
}
/* ---------- editor ---------- */
function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } | { kind: 'edit'; id: number } }) {
const [resolutions, setResolutions] = useState<Resolution[]>([]);
const [hwaccelKinds, setHwaccelKinds] = useState<HardwareAccelerationKind[]>(['None']);
const [draft, setDraft] = useState<Draft | null>(null);
const [loadError, setLoadError] = useState<null | string>(null);
const [saveError, setSaveError] = useState<null | string>(null);
const [saving, setSaving] = useState(false);
const isEdit = mode.kind === 'edit';
// Captured once at mount (the screen keys this editor by pathname, so a route change
// remounts rather than mutates) — mirrors ChannelEditScreen's stable-id pattern.
const [loadEditId] = useState(() => (mode.kind === 'edit' ? mode.id : null));
const [loadCopyFrom] = useState(() => (mode.kind === 'add' ? mode.from : null));
useEffect(() => {
let active = true;
const hydrate = async () => {
try {
const [resolutionList, kinds] = await Promise.all([getResolutions(), getHardwareAccelerationKinds()]);
let nextDraft: Draft;
if (loadEditId != null) {
nextDraft = draftFromProfile(await getFFmpegProfile(loadEditId));
} else if (loadCopyFrom != null) {
const source = await getFFmpegProfile(loadCopyFrom);
nextDraft = { ...draftFromProfile(source), name: `${source.name} (copy)` };
} else {
nextDraft = defaultDraft(resolutionList[0]?.id ?? 0);
}
if (!kinds.includes(nextDraft.hardwareAcceleration)) {
nextDraft = { ...nextDraft, hardwareAcceleration: 'None' };
}
if (active) {
setResolutions(resolutionList);
setHwaccelKinds(kinds);
setDraft(nextDraft);
}
} catch (error) {
if (active) {
setLoadError(messageFromFFmpegProfileError(error, 'Unable to load profile'));
}
}
};
void hydrate();
return () => {
active = false;
};
}, [loadEditId, loadCopyFrom]);
if (loadError) {
return (
<div className="ctv-settings-error" role="alert">
<TriangleAlert aria-hidden="true" size={16} />
<span>{loadError}</span>
<Button onClick={() => navigateToPath(BASE_PATH)} size="sm" variant="secondary">
Back to profiles
</Button>
</div>
);
}
if (!draft) {
return (
<div className="ctv-settings-loading">
<Spinner size={18} />
<span>Loading profile…</span>
</div>
);
}
const set = (patch: Partial<Draft>) => {
setDraft((current) => (current ? { ...current, ...patch } : current));
};
const validationError = validate(draft);
const presets = presetsFor(draft.hardwareAcceleration, draft.videoFormat, draft.bitDepth);
const save = async () => {
if (validationError || saving) {
return;
}
setSaving(true);
setSaveError(null);
try {
if (mode.kind === 'edit') {
await updateFFmpegProfile(mode.id, draft);
} else {
await createFFmpegProfile(draft);
}
navigateToPath(BASE_PATH);
} catch (error) {
setSaveError(messageFromFFmpegProfileError(error, 'Unable to save profile'));
} finally {
setSaving(false);
}
};
const resolutionOptions = resolutions.map((resolution) => ({
label: resolution.name ?? `${resolution.width}×${resolution.height}`,
value: String(resolution.id)
}));
const profileDisabled = draft.videoFormat !== 'H264' || !hwaccelSupportsH264Profile(draft.hardwareAcceleration);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button
onClick={() => navigateToPath(BASE_PATH)}
size="sm"
startIcon={<ArrowLeft aria-hidden="true" size={14} />}
variant="ghost"
>
All profiles
</Button>
<span className="ctv-collections-detail-title">{isEdit ? 'Edit FFmpeg profile' : 'New FFmpeg profile'}</span>
<span className="ctv-channels-spacer" />
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
{validationError && <Badge tone="neutral">{validationError}</Badge>}
<Button
disabled={validationError !== null || saving}
loading={saving}
onClick={() => void save()}
size="sm"
startIcon={<Check aria-hidden="true" size={14} />}
>
{isEdit ? 'Save profile' : 'Add profile'}
</Button>
</div>
<Card padded={false} title="General">
<Row control={360} first label="Name">
<Input
error={draft.name?.trim() ? null : 'Name is required'}
onChange={(event) => set({ name: event.target.value })}
size="sm"
value={draft.name ?? ''}
/>
</Row>
<Row control={200} label="Thread count">
<NumberField onChange={(threadCount) => set({ threadCount })} value={draft.threadCount} />
</Row>
<Row control={200} label="Normalize audio">
<Checkbox checked={draft.normalizeAudio} onChange={(normalizeAudio) => set({ normalizeAudio })} />
</Row>
<Row control={200} label="Normalize video">
<Checkbox checked={draft.normalizeVideo} onChange={(normalizeVideo) => set({ normalizeVideo })} />
</Row>
<Row control={360} label="Preferred resolution">
<Select
onChange={(event) => set({ resolutionId: Number(event.target.value) })}
options={resolutionOptions}
value={String(draft.resolutionId)}
/>
</Row>
{draft.normalizeVideo && (
<>
<Row control={360} label="Scaling behavior">
<Select
onChange={(event) => set({ scalingBehavior: event.target.value as Draft['scalingBehavior'] })}
options={SCALING_OPTIONS}
value={draft.scalingBehavior}
/>
</Row>
<Row
control={360}
help="Hardware padding only applies with VAAPI; otherwise software padding is used."
label="Pad mode"
>
<Select
disabled={draft.hardwareAcceleration !== 'Vaapi'}
onChange={(event) => set({ padMode: event.target.value as Draft['padMode'] })}
options={PAD_MODE_OPTIONS}
value={draft.padMode}
/>
</Row>
</>
)}
</Card>
<Card padded={false} title="Video">
{draft.normalizeVideo ? (
<>
<Row control={360} first label="Format">
<Select
onChange={(event) => set({ videoFormat: event.target.value as Draft['videoFormat'] })}
options={videoFormatOptions(draft.hardwareAcceleration)}
value={draft.videoFormat}
/>
</Row>
<Row control={360} label="Profile">
<Select
disabled={profileDisabled}
onChange={(event) => set({ videoProfile: event.target.value })}
options={videoProfileOptions(draft.hardwareAcceleration)}
value={draft.videoProfile ?? ''}
/>
</Row>
<Row control={360} label="Preset">
<Select
disabled={presets.length === 0}
onChange={(event) => set({ videoPreset: event.target.value })}
options={[{ label: '(none)', value: '' }, ...presets]}
value={draft.videoPreset ?? ''}
/>
</Row>
<Row control={200} label="Allow B-frames">
<Checkbox checked={draft.allowBFrames} onChange={(allowBFrames) => set({ allowBFrames })} />
</Row>
<Row control={360} label="Bit depth">
<Select
onChange={(event) => set({ bitDepth: event.target.value as Draft['bitDepth'] })}
options={BIT_DEPTH_OPTIONS}
value={draft.bitDepth}
/>
</Row>
<Row control={220} label="Bitrate">
<NumberField onChange={(videoBitrate) => set({ videoBitrate })} unit="kBit/s" value={draft.videoBitrate} />
</Row>
<Row control={220} label="Buffer size">
<NumberField onChange={(videoBufferSize) => set({ videoBufferSize })} unit="kBit" value={draft.videoBufferSize} />
</Row>
<Row control={360} label="Hardware acceleration">
<Select
onChange={(event) => set({ hardwareAcceleration: event.target.value as HardwareAccelerationKind })}
options={hwaccelKinds}
value={draft.hardwareAcceleration}
/>
</Row>
{draft.hardwareAcceleration === 'Vaapi' && (
<>
<Row control={360} label="VAAPI driver">
<Select
onChange={(event) => set({ vaapiDriver: event.target.value as Draft['vaapiDriver'] })}
options={VAAPI_DRIVER_OPTIONS}
value={draft.vaapiDriver}
/>
</Row>
<Row control={360} help="Rendering display, e.g. drm." label="VAAPI display">
<Input onChange={(event) => set({ vaapiDisplay: event.target.value })} size="sm" value={draft.vaapiDisplay ?? ''} />
</Row>
</>
)}
{(draft.hardwareAcceleration === 'Vaapi' || draft.hardwareAcceleration === 'Qsv') && (
<Row
control={360}
help="Render device, e.g. /dev/dri/renderD128."
label={draft.hardwareAcceleration === 'Vaapi' ? 'VAAPI device' : 'QSV device'}
>
<Input onChange={(event) => set({ vaapiDevice: event.target.value })} size="sm" value={draft.vaapiDevice ?? ''} />
</Row>
)}
{draft.hardwareAcceleration === 'Qsv' ? (
<Row control={200} label="QSV extra hardware frames">
<NumberField
onChange={(qsvExtraHardwareFrames) => set({ qsvExtraHardwareFrames })}
value={draft.qsvExtraHardwareFrames ?? 0}
/>
</Row>
) : (
<Row control={360} label="Tonemap algorithm">
<Select
onChange={(event) => set({ tonemapAlgorithm: event.target.value as Draft['tonemapAlgorithm'] })}
options={TONEMAP_OPTIONS}
value={draft.tonemapAlgorithm}
/>
</Row>
)}
<Row control={200} label="Normalize frame rate">
<Checkbox checked={draft.normalizeFramerate} onChange={(normalizeFramerate) => set({ normalizeFramerate })} />
</Row>
<Row control={200} label="Normalize colors">
<Checkbox checked={draft.normalizeColors} onChange={(normalizeColors) => set({ normalizeColors })} />
</Row>
<Row control={200} label="Auto deinterlace video">
<Checkbox checked={draft.deinterlaceVideo} onChange={(deinterlaceVideo) => set({ deinterlaceVideo })} />
</Row>
</>
) : (
<div className="ctv-settings-callout" style={{ margin: 16 }}>
<TriangleAlert aria-hidden="true" size={14} />
<span>Video will be copied as-is, including timestamps, which will cause issues with most clients.</span>
</div>
)}
</Card>
<Card padded={false} title="Audio">
{draft.normalizeAudio ? (
<>
<Row control={360} first label="Format">
<Select
onChange={(event) => set({ audioFormat: event.target.value as Draft['audioFormat'] })}
options={AUDIO_FORMAT_OPTIONS}
value={draft.audioFormat}
/>
</Row>
<Row control={220} label="Bitrate">
<NumberField onChange={(audioBitrate) => set({ audioBitrate })} unit="kBit/s" value={draft.audioBitrate} />
</Row>
<Row control={220} label="Buffer size">
<NumberField onChange={(audioBufferSize) => set({ audioBufferSize })} unit="kBit" value={draft.audioBufferSize} />
</Row>
<Row control={200} label="Channels">
<NumberField onChange={(audioChannels) => set({ audioChannels })} value={draft.audioChannels} />
</Row>
<Row control={220} label="Sample rate">
<NumberField onChange={(audioSampleRate) => set({ audioSampleRate })} unit="kHz" value={draft.audioSampleRate} />
</Row>
<Row control={360} label="Normalize loudness">
<Select
onChange={(event) => set({ normalizeLoudnessMode: event.target.value as Draft['normalizeLoudnessMode'] })}
options={LOUDNESS_OPTIONS}
value={draft.normalizeLoudnessMode}
/>
</Row>
{draft.normalizeLoudnessMode === 'LoudNorm' && (
<Row control={220} label="Target loudness">
<NumberField
onChange={(targetLoudness) => set({ targetLoudness })}
unit="LUFS"
value={draft.targetLoudness ?? 0}
/>
</Row>
)}
</>
) : (
<div className="ctv-settings-callout" style={{ margin: 16 }}>
<TriangleAlert aria-hidden="true" size={14} />
<span>Audio will be copied as-is, including timestamps, which will cause issues with most clients.</span>
</div>
)}
</Card>
</div>
);
}
+738
View File
@@ -0,0 +1,738 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Film, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import {
createFillerPreset,
deleteFillerPreset,
getFillerPreset,
getFillerPresets,
getLibraryBrowseItems,
messageFromFillerPresetError,
updateFillerPreset,
type CreateFillerPresetRequest,
type FillerPreset,
type LibraryBrowseItem,
type LibraryBrowseMediaType
} from '../api';
const BASE_PATH = '/app/filler-presets';
type Draft = CreateFillerPresetRequest;
type FillerKind = Draft['fillerKind'];
type FillerMode = Draft['fillerMode'];
type CollectionType = Draft['collectionType'];
const KIND_OPTIONS = [
{ label: 'Pre-Roll', value: 'PreRoll' },
{ label: 'Mid-Roll', value: 'MidRoll' },
{ label: 'Post-Roll', value: 'PostRoll' },
{ label: 'Tail', value: 'Tail' },
{ label: 'Fallback', value: 'Fallback' }
];
const MODE_OPTIONS = [
{ label: 'Duration', value: 'Duration' },
{ label: 'Count', value: 'Count' },
{ label: 'Pad', value: 'Pad' },
{ label: 'Random Count', value: 'RandomCount' }
];
const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
// CollectionType -> the library-browse media type + which request id field it fills.
const COLLECTION_TYPES: Array<{
browse: LibraryBrowseMediaType;
field: 'collectionId' | 'mediaItemId' | 'multiCollectionId' | 'playlistId' | 'smartCollectionId';
itemId: (item: LibraryBrowseItem) => null | number | undefined;
label: string;
playlistOnly?: boolean;
value: CollectionType;
}> = [
{ browse: 'Collection', field: 'collectionId', itemId: (item) => item.collectionId, label: 'Collection', value: 'Collection' },
{ browse: 'TelevisionShow', field: 'mediaItemId', itemId: (item) => item.mediaItemId ?? item.id, label: 'Television Show', value: 'TelevisionShow' },
{ browse: 'TelevisionSeason', field: 'mediaItemId', itemId: (item) => item.mediaItemId ?? item.id, label: 'Television Season', value: 'TelevisionSeason' },
{ browse: 'Artist', field: 'mediaItemId', itemId: (item) => item.mediaItemId ?? item.id, label: 'Artist', value: 'Artist' },
{ browse: 'MultiCollection', field: 'multiCollectionId', itemId: (item) => item.multiCollectionId, label: 'Multi Collection', value: 'MultiCollection' },
{ browse: 'SmartCollection', field: 'smartCollectionId', itemId: (item) => item.smartCollectionId, label: 'Smart Collection', value: 'SmartCollection' },
{ browse: 'Playlist', field: 'playlistId', itemId: (item) => item.playlistId, label: 'Playlist', playlistOnly: true, value: 'Playlist' }
];
const COLLECTION_ID_FIELDS = ['collectionId', 'mediaItemId', 'multiCollectionId', 'smartCollectionId', 'playlistId'] as const;
/* ---------- TimeSpan (constant "c" format) helpers ---------- */
function timeSpanToParts(value: null | string | undefined): { hours: number; minutes: number; seconds: number } {
if (!value) {
return { hours: 0, minutes: 0, seconds: 0 };
}
const body = value.replace(/^-/, '');
const segments = body.split(':');
let hoursSegment = segments[0] ?? '0';
let days = 0;
if (hoursSegment.includes('.')) {
const [d, h] = hoursSegment.split('.');
days = Number(d);
hoursSegment = h;
}
return {
hours: days * 24 + Number(hoursSegment),
minutes: Number(segments[1] ?? 0),
seconds: Math.trunc(Number(segments[2] ?? 0))
};
}
function partsToTimeSpan(hours: number, minutes: number, seconds: number): null | string {
if (!hours && !minutes && !seconds) {
return null;
}
const days = Math.floor(hours / 24);
const hh = hours % 24;
const mm = String(minutes).padStart(2, '0');
const ss = String(seconds).padStart(2, '0');
return days > 0 ? `${days}.${String(hh).padStart(2, '0')}:${mm}:${ss}` : `${hh}:${mm}:${ss}`;
}
/* ---------- validation (mirrors FillerPresetEditViewModelValidator) ---------- */
function validate(draft: Draft): null | string {
if (!draft.name.trim()) {
return 'Name is required';
}
if (draft.fillerKind !== 'Fallback' && draft.fillerKind !== 'Tail' && draft.fillerMode === 'None') {
return 'Mode is required';
}
if (draft.fillerMode === 'Count' && (draft.count == null || draft.count < 0)) {
return 'Count is required';
}
if (draft.fillerMode === 'Duration' && draft.duration == null) {
return 'Duration is required';
}
if (draft.fillerMode === 'Pad' && draft.padToNearestMinute == null) {
return 'Pad-to-nearest-minute is required';
}
if (draft.fillerKind === 'Fallback' && draft.useChaptersAsMediaItems) {
return 'Fallback filler cannot use chapters as media items';
}
if (draft.collectionType === 'Collection' && draft.collectionId == null) {
return 'A collection is required';
}
if (draft.collectionType === 'MultiCollection' && draft.multiCollectionId == null) {
return 'A multi collection is required';
}
if (draft.collectionType === 'SmartCollection' && draft.smartCollectionId == null) {
return 'A smart collection is required';
}
if (
(draft.collectionType === 'Artist' ||
draft.collectionType === 'TelevisionShow' ||
draft.collectionType === 'TelevisionSeason') &&
draft.mediaItemId == null
) {
return 'A media item is required';
}
if (draft.collectionType === 'Playlist' && draft.playlistId == null) {
return 'A playlist is required';
}
return null;
}
function defaultDraft(): Draft {
return {
allowWatermarks: false,
collectionId: null,
collectionType: 'Collection',
count: null,
duration: null,
expression: null,
fillerKind: 'PreRoll',
fillerMode: 'Count',
mediaItemId: null,
multiCollectionId: null,
name: '',
padToNearestMinute: null,
playlistId: null,
smartCollectionId: null,
useChaptersAsMediaItems: false
};
}
function draftFromPreset(preset: FillerPreset & Draft): Draft {
return {
allowWatermarks: preset.allowWatermarks,
collectionId: preset.collectionId,
collectionType: preset.collectionType,
count: preset.count,
duration: preset.duration,
expression: preset.expression,
fillerKind: preset.fillerKind,
fillerMode: preset.fillerMode,
mediaItemId: preset.mediaItemId,
multiCollectionId: preset.multiCollectionId,
name: preset.name,
padToNearestMinute: preset.padToNearestMinute,
playlistId: preset.playlistId,
smartCollectionId: preset.smartCollectionId,
useChaptersAsMediaItems: preset.useChaptersAsMediaItems
};
}
// Apply the kind-driven interlocks from FillerPresetEditViewModel's setters.
function applyKind(draft: Draft, fillerKind: FillerKind): Draft {
const next: Draft = { ...draft, fillerKind };
if (fillerKind === 'Fallback' || fillerKind === 'Tail') {
next.fillerMode = 'None';
}
if (fillerKind !== 'MidRoll') {
next.expression = null;
}
if (fillerKind === 'Fallback') {
next.useChaptersAsMediaItems = false;
// Playlist is not offered for Fallback/Tail; fall back to Collection if it was selected.
if (next.collectionType === 'Playlist') {
next.collectionType = 'Collection';
}
}
if ((fillerKind === 'Tail') && next.collectionType === 'Playlist') {
next.collectionType = 'Collection';
}
return next;
}
function clearCollectionRefs(draft: Draft): Draft {
const next = { ...draft };
for (const field of COLLECTION_ID_FIELDS) {
next[field] = null;
}
return next;
}
/* ---------- form row helper ---------- */
function Row({
children,
control = 320,
first = false,
help,
label
}: {
children: ReactNode;
control?: number;
first?: boolean;
help?: string;
label: string;
}) {
return (
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
<div className="ctv-settings-row-main">
<div className="ctv-settings-row-label">{label}</div>
{help && <div className="ctv-settings-row-help">{help}</div>}
</div>
<div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>{children}</div>
</div>
);
}
/* ---------- routing ---------- */
type Mode = { id: number; kind: 'edit' } | { kind: 'add' } | { kind: 'list' };
function modeFromPath(pathname: string): Mode {
const normalized = pathname.replace(/\/+$/, '');
if (normalized === BASE_PATH || !normalized.startsWith(`${BASE_PATH}/`)) {
return { kind: 'list' };
}
const suffix = normalized.slice(BASE_PATH.length + 1).split('/')[0];
if (suffix === 'add') {
return { kind: 'add' };
}
const id = Number(suffix);
return Number.isInteger(id) && id > 0 ? { id, kind: 'edit' } : { kind: 'list' };
}
export function FillerPresetsScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
useEffect(() => {
const onPop = () => setPathname(window.location.pathname);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const mode = modeFromPath(pathname);
if (mode.kind === 'list') {
return <FillerList key={pathname} />;
}
return <FillerEditor key={pathname} mode={mode} />;
}
/* ---------- list ---------- */
function FillerList() {
const [presets, setPresets] = useState<FillerPreset[] | null>(null);
const [error, setError] = useState<null | string>(null);
const [deleteTarget, setDeleteTarget] = useState<FillerPreset | null>(null);
const [busy, setBusy] = useState(false);
const activeRef = useRef(true);
const load = useCallback(() => {
getFillerPresets()
.then((data) => {
if (activeRef.current) {
setPresets(data);
setError(null);
}
})
.catch((loadError: unknown) => {
if (activeRef.current) {
setError(messageFromFillerPresetError(loadError));
}
});
}, []);
useEffect(() => {
activeRef.current = true;
load();
return () => {
activeRef.current = false;
};
}, [load]);
const confirmDelete = async () => {
if (!deleteTarget) {
return;
}
setBusy(true);
try {
await deleteFillerPreset(deleteTarget.id);
setDeleteTarget(null);
load();
} catch (deleteError) {
setError(messageFromFillerPresetError(deleteError, 'Unable to delete filler preset'));
} finally {
setBusy(false);
}
};
if (error && !presets) {
return (
<div className="ctv-collections">
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={load} size="sm" variant="secondary">
Retry
</Button>
</div>
</div>
);
}
if (!presets) {
return (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading filler presets…</span>
</div>
);
}
const sorted = [...presets].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Badge tone="neutral">{presets.length} presets</Badge>
<span className="ctv-channels-spacer" />
<Button
onClick={() => navigateToPath(`${BASE_PATH}/add`)}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
New filler preset
</Button>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
)}
<Card padded={false}>
{sorted.length === 0 ? (
<div className="ctv-collections-empty">No filler presets yet.</div>
) : (
sorted.map((preset, index) => (
<div
className="ctv-settings-flush-row"
key={preset.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<Film aria-hidden="true" color="var(--ctv-accent)" size={15} />
<button
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
onClick={() => navigateToPath(`${BASE_PATH}/${preset.id}`)}
type="button"
>
{preset.name}
</button>
<IconButton onClick={() => setDeleteTarget(preset)} size="sm" title="Delete" variant="ghost">
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
<ConfirmDialog
busy={busy}
confirmLabel="Delete"
message={deleteTarget ? `Delete "${deleteTarget.name}"? This cannot be undone.` : ''}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
open={deleteTarget !== null}
title="Delete filler preset"
tone="danger"
/>
</div>
);
}
/* ---------- editor ---------- */
function FillerEditor({ mode }: { mode: { id: number; kind: 'edit' } | { kind: 'add' } }) {
const [draft, setDraft] = useState<Draft | null>(mode.kind === 'add' ? defaultDraft : null);
const [loadError, setLoadError] = useState<null | string>(null);
const [saveError, setSaveError] = useState<null | string>(null);
const [saving, setSaving] = useState(false);
const [pickerItems, setPickerItems] = useState<LibraryBrowseItem[]>([]);
const [pickerError, setPickerError] = useState<null | string>(null);
const isEdit = mode.kind === 'edit';
// Captured once at mount; the screen keys this editor by pathname (see ChannelEditScreen).
const [loadEditId] = useState(() => (mode.kind === 'edit' ? mode.id : null));
useEffect(() => {
if (loadEditId == null) {
return;
}
let active = true;
getFillerPreset(loadEditId)
.then((preset) => {
if (active) {
setDraft(draftFromPreset(preset as FillerPreset & Draft));
}
})
.catch((error: unknown) => {
if (active) {
setLoadError(messageFromFillerPresetError(error, 'Unable to load filler preset'));
}
});
return () => {
active = false;
};
}, [loadEditId]);
const collectionType = draft?.collectionType;
// Load the browse items for the active collection type so the picker can show names. All
// resets happen in the async callbacks (never synchronously in the effect body).
useEffect(() => {
const config = COLLECTION_TYPES.find((entry) => entry.value === collectionType);
if (!config) {
return;
}
let active = true;
getLibraryBrowseItems({ mediaType: config.browse, pageSize: 500 })
.then((result) => {
if (active) {
setPickerItems(result.page ?? []);
setPickerError(null);
}
})
.catch((error: unknown) => {
if (active) {
setPickerItems([]);
setPickerError(messageFromFillerPresetError(error, 'Unable to load picker items'));
}
});
return () => {
active = false;
};
}, [collectionType]);
if (loadError) {
return (
<div className="ctv-settings-error" role="alert">
<TriangleAlert aria-hidden="true" size={16} />
<span>{loadError}</span>
<Button onClick={() => navigateToPath(BASE_PATH)} size="sm" variant="secondary">
Back to filler presets
</Button>
</div>
);
}
if (!draft) {
return (
<div className="ctv-settings-loading">
<Spinner size={18} />
<span>Loading filler preset…</span>
</div>
);
}
const set = (patch: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...patch } : current));
const setKind = (fillerKind: FillerKind) => setDraft((current) => (current ? applyKind(current, fillerKind) : current));
const setCollectionType = (type: CollectionType) => {
setDraft((current) => {
if (!current) {
return current;
}
const cleared = clearCollectionRefs(current);
cleared.collectionType = type;
// Playlist collection type forces Count mode (matches the Blazor VM).
if (type === 'Playlist') {
cleared.fillerMode = 'Count';
}
return cleared;
});
};
const validationError = validate(draft);
const modeLocked = draft.fillerKind === 'Fallback' || draft.fillerKind === 'Tail' || draft.collectionType === 'Playlist';
const durationParts = timeSpanToParts(draft.duration);
const activeConfig = COLLECTION_TYPES.find((entry) => entry.value === draft.collectionType);
const setDurationPart = (patch: Partial<{ hours: number; minutes: number; seconds: number }>) => {
const next = { ...durationParts, ...patch };
set({ duration: partsToTimeSpan(next.hours, next.minutes, next.seconds) });
};
const pickerValue = activeConfig ? String((draft[activeConfig.field] as null | number) ?? '') : '';
const pickerOptions = [
{ label: '(none)', value: '' },
...pickerItems.flatMap((item) => {
const id = activeConfig?.itemId(item);
return id == null ? [] : [{ label: item.title ?? `#${id}`, value: String(id) }];
})
];
const collectionTypeOptions = COLLECTION_TYPES.filter(
(entry) => !entry.playlistOnly || (draft.fillerKind !== 'Fallback' && draft.fillerKind !== 'Tail')
).map((entry) => ({ label: entry.label, value: entry.value }));
const save = async () => {
if (validationError || saving) {
return;
}
setSaving(true);
setSaveError(null);
try {
if (mode.kind === 'edit') {
await updateFillerPreset(mode.id, draft);
} else {
await createFillerPreset(draft);
}
navigateToPath(BASE_PATH);
} catch (error) {
setSaveError(messageFromFillerPresetError(error, 'Unable to save filler preset'));
} finally {
setSaving(false);
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button
onClick={() => navigateToPath(BASE_PATH)}
size="sm"
startIcon={<ArrowLeft aria-hidden="true" size={14} />}
variant="ghost"
>
All filler presets
</Button>
<span className="ctv-collections-detail-title">{isEdit ? 'Edit filler preset' : 'New filler preset'}</span>
<span className="ctv-channels-spacer" />
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
{validationError && <Badge tone="neutral">{validationError}</Badge>}
<Button
disabled={validationError !== null || saving}
loading={saving}
onClick={() => void save()}
size="sm"
startIcon={<Check aria-hidden="true" size={14} />}
>
{isEdit ? 'Save filler preset' : 'Add filler preset'}
</Button>
</div>
<Card padded={false} title="Filler preset">
<Row control={360} first label="Name">
<Input
error={draft.name.trim() ? null : 'Name is required'}
onChange={(event) => set({ name: event.target.value })}
size="sm"
value={draft.name}
/>
</Row>
<Row control={360} help={isEdit ? 'Kind cannot be changed after creation.' : undefined} label="Kind">
<Select
disabled={isEdit}
onChange={(event) => setKind(event.target.value as FillerKind)}
options={KIND_OPTIONS}
value={draft.fillerKind}
/>
</Row>
<Row control={360} label="Mode">
<Select
disabled={modeLocked}
onChange={(event) => set({ fillerMode: event.target.value as FillerMode })}
options={MODE_OPTIONS}
value={draft.fillerMode === 'None' ? '' : draft.fillerMode}
/>
</Row>
<Row control={360} label="Duration">
<div style={{ display: 'flex', gap: 8 }}>
<Input
disabled={draft.fillerMode !== 'Duration'}
onChange={(event) => setDurationPart({ hours: Number(event.target.value) })}
size="sm"
trailing="h"
type="number"
value={String(durationParts.hours)}
/>
<Input
disabled={draft.fillerMode !== 'Duration'}
onChange={(event) => setDurationPart({ minutes: Number(event.target.value) })}
size="sm"
trailing="m"
type="number"
value={String(durationParts.minutes)}
/>
<Input
disabled={draft.fillerMode !== 'Duration'}
onChange={(event) => setDurationPart({ seconds: Number(event.target.value) })}
size="sm"
trailing="s"
type="number"
value={String(durationParts.seconds)}
/>
</div>
</Row>
<Row control={200} label="Count">
<Input
disabled={draft.fillerMode !== 'Count' && draft.fillerMode !== 'RandomCount'}
onChange={(event) => set({ count: event.target.value === '' ? null : Number(event.target.value) })}
size="sm"
type="number"
value={draft.count == null ? '' : String(draft.count)}
/>
</Row>
<Row control={360} label="Pad to nearest minute">
<Select
disabled={draft.fillerMode !== 'Pad'}
onChange={(event) =>
set({ padToNearestMinute: event.target.value === '' ? null : Number(event.target.value) })
}
options={[{ label: '(none)', value: '' }, ...PAD_OPTIONS]}
value={draft.padToNearestMinute == null ? '' : String(draft.padToNearestMinute)}
/>
</Row>
<Row control={200} label="Allow watermarks">
<Checkbox checked={draft.allowWatermarks} onChange={(allowWatermarks) => set({ allowWatermarks })} />
</Row>
<Row control={360} label="Collection type">
<Select
onChange={(event) => setCollectionType(event.target.value as CollectionType)}
options={collectionTypeOptions}
value={draft.collectionType}
/>
</Row>
{activeConfig && (
<Row control={360} help={pickerError ?? undefined} label={activeConfig.label}>
<Select
onChange={(event) =>
set({ [activeConfig.field]: event.target.value === '' ? null : Number(event.target.value) })
}
options={pickerOptions}
value={pickerValue}
/>
</Row>
)}
<Row
control={200}
help="Schedule individual chapters instead of entire files."
label="Use chapters as media items"
>
<Checkbox
checked={draft.useChaptersAsMediaItems}
disabled={draft.fillerKind === 'Fallback'}
onChange={(useChaptersAsMediaItems) => set({ useChaptersAsMediaItems })}
/>
</Row>
<Row
control={360}
help="Mid-roll only: add filler only when this expression is true for a mid-roll point."
label="Expression"
>
<Input
disabled={draft.fillerKind !== 'MidRoll'}
onChange={(event) => set({ expression: event.target.value })}
size="sm"
value={draft.expression ?? ''}
/>
</Row>
</Card>
</div>
);
}
+10 -3
View File
@@ -603,8 +603,15 @@ function StreamingPane({
</div>
))
)}
<div className="ctv-settings-flush-footer">
<LegacyCallout>Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.</LegacyCallout>
<div className="ctv-settings-flush-footer" style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
endIcon={<ArrowRight aria-hidden="true" size={13} />}
onClick={() => navigateToPath('/app/ffmpeg-profiles')}
size="sm"
variant="secondary"
>
Manage FFmpeg profiles
</Button>
</div>
</Card>
</Pane>
@@ -854,7 +861,7 @@ function SystemPane({
Open Dashboard
</Button>
</Row>
<Row control={220} help="Media browse/search, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates and playout editors still live here." label="Classic UI">
<Row control={220} help="Media browse/search, trakt, blocks/decos/templates and playout editors still live here." label="Classic UI">
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
<span>Open Classic UI</span>
<ExternalLink aria-hidden="true" size={13} />
+628
View File
@@ -0,0 +1,628 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Image as ImageIcon, Plus, Trash2, TriangleAlert, Upload } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import {
createWatermark,
deleteWatermark,
getWatermark,
getWatermarks,
messageFromWatermarkError,
updateWatermark,
uploadArtwork,
watermarkImageUrl,
type CreateWatermarkRequest,
type Watermark
} from '../api';
const BASE_PATH = '/app/watermarks';
type Draft = CreateWatermarkRequest;
const MODE_OPTIONS = [
{ label: 'None', value: 'None' },
{ label: 'Permanent', value: 'Permanent' },
{ label: 'Intermittent', value: 'Intermittent' },
{ label: 'Opacity Expression', value: 'OpacityExpression' }
];
const IMAGE_SOURCE_OPTIONS = [
{ label: 'Custom', value: 'Custom' },
{ label: 'Channel Logo', value: 'ChannelLogo' }
];
// Blazor location order (WatermarkEditor.razor); MiddleCenter is intentionally not offered.
const LOCATION_OPTIONS = [
{ label: 'Bottom Right', value: 'BottomRight' },
{ label: 'Bottom Middle', value: 'BottomMiddle' },
{ label: 'Bottom Left', value: 'BottomLeft' },
{ label: 'Left Middle', value: 'LeftMiddle' },
{ label: 'Top Left', value: 'TopLeft' },
{ label: 'Top Middle', value: 'TopMiddle' },
{ label: 'Top Right', value: 'TopRight' },
{ label: 'Right Middle', value: 'RightMiddle' }
];
const SIZE_OPTIONS = [
{ label: 'Scaled', value: 'Scaled' },
{ label: 'Actual Size', value: 'ActualSize' }
];
const FREQUENCY_OPTIONS = [5, 10, 15, 20, 30, 60].map((minutes) => ({
label: `${minutes} minutes`,
value: String(minutes)
}));
// Mirrors WatermarkEditViewModelValidator. Returns the first blocking message, or null.
function validate(draft: Draft): null | string {
if (!draft.name.trim()) {
return 'Name is required';
}
if (draft.imageSource === 'Custom' && !draft.image) {
return 'Watermark image is required';
}
if (draft.mode === 'None') {
return null;
}
if (draft.size === 'Scaled' && (draft.width <= 0 || draft.width > 100)) {
return 'Width must be between 0 and 100';
}
if (draft.horizontalMargin < 0 || draft.horizontalMargin > 50) {
return 'Horizontal margin must be between 0 and 50';
}
if (draft.verticalMargin < 0 || draft.verticalMargin > 50) {
return 'Vertical margin must be between 0 and 50';
}
if (draft.durationSeconds <= 0 || draft.durationSeconds >= draft.frequencyMinutes * 60) {
return 'Duration must be greater than 0 and less than the frequency';
}
if (draft.opacity <= 0 || draft.opacity > 100) {
return 'Opacity must be between 0 and 100';
}
return null;
}
function defaultDraft(): Draft {
return {
durationSeconds: 15,
frequencyMinutes: 15,
horizontalMargin: 5,
image: null,
imageContentType: null,
imageSource: 'Custom',
location: 'BottomRight',
mode: 'Permanent',
name: '',
opacity: 100,
opacityExpression: null,
placeWithinSourceContent: false,
size: 'Scaled',
verticalMargin: 5,
width: 15,
zIndex: 0
};
}
function draftFromWatermark(watermark: Watermark & Draft): Draft {
return {
durationSeconds: watermark.durationSeconds,
frequencyMinutes: watermark.frequencyMinutes,
horizontalMargin: watermark.horizontalMargin,
image: watermark.image,
imageContentType: watermark.imageContentType,
imageSource: watermark.imageSource,
location: watermark.location,
mode: watermark.mode,
name: watermark.name,
opacity: watermark.opacity,
opacityExpression: watermark.opacityExpression,
placeWithinSourceContent: watermark.placeWithinSourceContent,
size: watermark.size,
verticalMargin: watermark.verticalMargin,
width: watermark.width,
zIndex: watermark.zIndex
};
}
function Row({
children,
control = 320,
first = false,
help,
label
}: {
children: ReactNode;
control?: number;
first?: boolean;
help?: string;
label: string;
}) {
return (
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
<div className="ctv-settings-row-main">
<div className="ctv-settings-row-label">{label}</div>
{help && <div className="ctv-settings-row-help">{help}</div>}
</div>
<div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>{children}</div>
</div>
);
}
type Mode = { id: number; kind: 'edit' } | { kind: 'add' } | { kind: 'list' };
function modeFromPath(pathname: string): Mode {
const normalized = pathname.replace(/\/+$/, '');
if (normalized === BASE_PATH || !normalized.startsWith(`${BASE_PATH}/`)) {
return { kind: 'list' };
}
const suffix = normalized.slice(BASE_PATH.length + 1).split('/')[0];
if (suffix === 'add') {
return { kind: 'add' };
}
const id = Number(suffix);
return Number.isInteger(id) && id > 0 ? { id, kind: 'edit' } : { kind: 'list' };
}
export function WatermarksScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
useEffect(() => {
const onPop = () => setPathname(window.location.pathname);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const mode = modeFromPath(pathname);
if (mode.kind === 'list') {
return <WatermarkList key={pathname} />;
}
return <WatermarkEditor key={pathname} mode={mode} />;
}
function WatermarkList() {
const [watermarks, setWatermarks] = useState<Watermark[] | null>(null);
const [error, setError] = useState<null | string>(null);
const [deleteTarget, setDeleteTarget] = useState<Watermark | null>(null);
const [busy, setBusy] = useState(false);
const activeRef = useRef(true);
const load = useCallback(() => {
getWatermarks()
.then((data) => {
if (activeRef.current) {
setWatermarks(data);
setError(null);
}
})
.catch((loadError: unknown) => {
if (activeRef.current) {
setError(messageFromWatermarkError(loadError));
}
});
}, []);
useEffect(() => {
activeRef.current = true;
load();
return () => {
activeRef.current = false;
};
}, [load]);
const confirmDelete = async () => {
if (!deleteTarget) {
return;
}
setBusy(true);
try {
await deleteWatermark(deleteTarget.id);
setDeleteTarget(null);
load();
} catch (deleteError) {
setError(messageFromWatermarkError(deleteError, 'Unable to delete watermark'));
} finally {
setBusy(false);
}
};
if (error && !watermarks) {
return (
<div className="ctv-collections">
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={load} size="sm" variant="secondary">
Retry
</Button>
</div>
</div>
);
}
if (!watermarks) {
return (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading watermarks…</span>
</div>
);
}
const sorted = [...watermarks].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Badge tone="neutral">{watermarks.length} watermarks</Badge>
<span className="ctv-channels-spacer" />
<Button
onClick={() => navigateToPath(`${BASE_PATH}/add`)}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
New watermark
</Button>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
)}
<Card padded={false}>
{sorted.length === 0 ? (
<div className="ctv-collections-empty">No watermarks yet.</div>
) : (
sorted.map((watermark, index) => (
<div
className="ctv-settings-flush-row"
key={watermark.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<ImageIcon aria-hidden="true" color="var(--ctv-accent)" size={15} />
<button
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
onClick={() => navigateToPath(`${BASE_PATH}/${watermark.id}`)}
type="button"
>
{watermark.name}
</button>
<IconButton onClick={() => setDeleteTarget(watermark)} size="sm" title="Delete" variant="ghost">
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
<ConfirmDialog
busy={busy}
confirmLabel="Delete"
message={deleteTarget ? `Delete "${deleteTarget.name}"? This cannot be undone.` : ''}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
open={deleteTarget !== null}
title="Delete watermark"
tone="danger"
/>
</div>
);
}
function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { kind: 'add' } }) {
const [draft, setDraft] = useState<Draft | null>(mode.kind === 'add' ? defaultDraft : null);
const [loadError, setLoadError] = useState<null | string>(null);
const [saveError, setSaveError] = useState<null | string>(null);
const [uploadError, setUploadError] = useState<null | string>(null);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const isEdit = mode.kind === 'edit';
// Captured once at mount; the screen keys this editor by pathname (see ChannelEditScreen).
const [loadEditId] = useState(() => (mode.kind === 'edit' ? mode.id : null));
useEffect(() => {
if (loadEditId == null) {
return;
}
let active = true;
getWatermark(loadEditId)
.then((watermark) => {
if (active) {
setDraft(draftFromWatermark(watermark as Watermark & Draft));
}
})
.catch((error: unknown) => {
if (active) {
setLoadError(messageFromWatermarkError(error, 'Unable to load watermark'));
}
});
return () => {
active = false;
};
}, [loadEditId]);
if (loadError) {
return (
<div className="ctv-settings-error" role="alert">
<TriangleAlert aria-hidden="true" size={16} />
<span>{loadError}</span>
<Button onClick={() => navigateToPath(BASE_PATH)} size="sm" variant="secondary">
Back to watermarks
</Button>
</div>
);
}
if (!draft) {
return (
<div className="ctv-settings-loading">
<Spinner size={18} />
<span>Loading watermark…</span>
</div>
);
}
const set = (patch: Partial<Draft>) => {
setDraft((current) => (current ? { ...current, ...patch } : current));
};
const validationError = validate(draft);
const modeNone = draft.mode === 'None';
const custom = draft.imageSource === 'Custom';
const upload = async (file: File) => {
if (!file.type.startsWith('image/')) {
setUploadError('Choose an image file.');
return;
}
setUploading(true);
setUploadError(null);
try {
const uploaded = await uploadArtwork(file, 'watermark');
set({ image: uploaded.path, imageContentType: uploaded.contentType });
} catch (error) {
setUploadError(messageFromWatermarkError(error, 'Unable to upload image'));
} finally {
setUploading(false);
}
};
const save = async () => {
if (validationError || saving) {
return;
}
setSaving(true);
setSaveError(null);
try {
if (mode.kind === 'edit') {
await updateWatermark(mode.id, draft);
} else {
await createWatermark(draft);
}
navigateToPath(BASE_PATH);
} catch (error) {
setSaveError(messageFromWatermarkError(error, 'Unable to save watermark'));
} finally {
setSaving(false);
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button
onClick={() => navigateToPath(BASE_PATH)}
size="sm"
startIcon={<ArrowLeft aria-hidden="true" size={14} />}
variant="ghost"
>
All watermarks
</Button>
<span className="ctv-collections-detail-title">{isEdit ? 'Edit watermark' : 'New watermark'}</span>
<span className="ctv-channels-spacer" />
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
{validationError && <Badge tone="neutral">{validationError}</Badge>}
<Button
disabled={validationError !== null || saving}
loading={saving}
onClick={() => void save()}
size="sm"
startIcon={<Check aria-hidden="true" size={14} />}
>
{isEdit ? 'Save watermark' : 'Add watermark'}
</Button>
</div>
<Card padded={false} title="Watermark">
<Row control={360} first label="Name">
<Input
error={draft.name.trim() ? null : 'Name is required'}
onChange={(event) => set({ name: event.target.value })}
size="sm"
value={draft.name}
/>
</Row>
<Row control={360} label="Mode">
<Select
onChange={(event) => set({ mode: event.target.value as Draft['mode'] })}
options={MODE_OPTIONS}
value={draft.mode}
/>
</Row>
<Row control={360} label="Image source">
<Select
disabled={modeNone}
onChange={(event) => set({ imageSource: event.target.value as Draft['imageSource'] })}
options={IMAGE_SOURCE_OPTIONS}
value={draft.imageSource}
/>
</Row>
<Row control={360} help="Only used with a custom image source." label="Image">
<input
accept="image/*"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void upload(file);
}
}}
ref={fileRef}
style={{ display: 'none' }}
type="file"
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Button
disabled={!custom}
loading={uploading}
onClick={() => fileRef.current?.click()}
size="sm"
startIcon={<Upload aria-hidden="true" size={14} />}
variant="secondary"
>
Upload image
</Button>
{custom && draft.image && (
<img
alt="Watermark preview"
src={watermarkImageUrl(draft.image, draft.imageContentType)}
style={{ maxHeight: 60, maxWidth: 200, objectFit: 'contain' }}
/>
)}
{uploadError && (
<span className="ctv-field-error" role="alert">
{uploadError}
</span>
)}
</div>
</Row>
<Row control={360} label="Location">
<Select
disabled={modeNone}
onChange={(event) => set({ location: event.target.value as Draft['location'] })}
options={LOCATION_OPTIONS}
value={draft.location}
/>
</Row>
<Row control={200} label="Place within source content">
<Checkbox
checked={draft.placeWithinSourceContent}
disabled={modeNone}
onChange={(placeWithinSourceContent) => set({ placeWithinSourceContent })}
/>
</Row>
<Row control={360} label="Size">
<Select
disabled={modeNone}
onChange={(event) => set({ size: event.target.value as Draft['size'] })}
options={SIZE_OPTIONS}
value={draft.size}
/>
</Row>
<Row control={220} label="Width">
<Input
disabled={modeNone || draft.size === 'ActualSize'}
onChange={(event) => set({ width: Number(event.target.value) })}
size="sm"
trailing="%"
type="number"
value={String(draft.width)}
/>
</Row>
<Row control={220} label="Horizontal margin">
<Input
disabled={modeNone}
onChange={(event) => set({ horizontalMargin: Number(event.target.value) })}
size="sm"
trailing="%"
type="number"
value={String(draft.horizontalMargin)}
/>
</Row>
<Row control={220} label="Vertical margin">
<Input
disabled={modeNone}
onChange={(event) => set({ verticalMargin: Number(event.target.value) })}
size="sm"
trailing="%"
type="number"
value={String(draft.verticalMargin)}
/>
</Row>
<Row control={360} label="Frequency">
<Select
disabled={draft.mode !== 'Intermittent'}
onChange={(event) => set({ frequencyMinutes: Number(event.target.value) })}
options={FREQUENCY_OPTIONS}
value={String(draft.frequencyMinutes)}
/>
</Row>
<Row control={220} label="Duration">
<Input
disabled={draft.mode !== 'Intermittent'}
onChange={(event) => set({ durationSeconds: Number(event.target.value) })}
size="sm"
trailing="seconds"
type="number"
value={String(draft.durationSeconds)}
/>
</Row>
<Row control={220} label="Opacity">
<Input
disabled={modeNone || draft.mode === 'OpacityExpression'}
onChange={(event) => set({ opacity: Number(event.target.value) })}
size="sm"
trailing="%"
type="number"
value={String(draft.opacity)}
/>
</Row>
<Row control={360} label="Opacity expression">
<Input
disabled={draft.mode !== 'OpacityExpression'}
onChange={(event) => set({ opacityExpression: event.target.value })}
size="sm"
value={draft.opacityExpression ?? ''}
/>
</Row>
<Row control={200} label="Z-index">
<Input
onChange={(event) => set({ zIndex: Number(event.target.value) })}
size="sm"
type="number"
value={String(draft.zIndex)}
/>
</Row>
</Card>
</div>
);
}