feat(web+api): FFmpeg profile, filler preset & watermark editors for SPA parity (#143) #166
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,9 +399,11 @@
|
||||
ValidationResult result = await _validator.ValidateAsync(_model, _cts.Token);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IRequest<Either<BaseError, Unit>> request = IsEdit ? _model.ToEdit() : _model.ToUpdate();
|
||||
|
||||
Seq<BaseError> errorMessage = (await Mediator.Send(request, _cts.Token)).LeftToSeq();
|
||||
// Create/Update return different result types (Update -> Unit, Create -> CreateFillerPresetResult),
|
||||
// so send each on its own branch and reduce to the shared Left error sequence.
|
||||
Seq<BaseError> errorMessage = IsEdit
|
||||
? (await Mediator.Send(_model.ToEdit(), _cts.Token)).LeftToSeq()
|
||||
: (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
|
||||
+6
-1
@@ -134,7 +134,12 @@ public class Startup
|
||||
// a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps.
|
||||
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
|
||||
.Where(type => type.IsEnum)
|
||||
.Concat([typeof(FFmpeg.OutputFormat.OutputFormatKind), typeof(Serilog.Events.LogEventLevel)])
|
||||
.Concat([
|
||||
typeof(FFmpeg.OutputFormat.OutputFormatKind),
|
||||
typeof(FFmpeg.State.WatermarkLocation),
|
||||
typeof(FFmpeg.State.WatermarkSize),
|
||||
typeof(Serilog.Events.LogEventLevel)
|
||||
])
|
||||
.GroupBy(type => type.Name)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ public class FillerPresetEditViewModel
|
||||
Expression,
|
||||
UseChaptersAsMediaItems);
|
||||
|
||||
public IRequest<Either<BaseError, Unit>> ToUpdate() =>
|
||||
public IRequest<Either<BaseError, CreateFillerPresetResult>> ToUpdate() =>
|
||||
new CreateFillerPreset(
|
||||
Name,
|
||||
FillerKind,
|
||||
|
||||
+1439
-30
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+122
-9
@@ -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"];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user