Browse Source

rework watermarks (#261)

* rework watermarks to be separate from channels

* update changelog
pull/262/head
Jason Dove 5 years ago committed by GitHub
parent
commit
72d967946d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      CHANGELOG.md
  2. 11
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  3. 11
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  4. 53
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  5. 11
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  6. 69
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  7. 11
      ErsatzTV.Application/Channels/Mapper.cs
  8. 8
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  9. 2
      ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs
  10. 14
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs
  11. 9
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  12. 9
      ErsatzTV.Application/Watermarks/Commands/CopyWatermark.cs
  13. 54
      ErsatzTV.Application/Watermarks/Commands/CopyWatermarkHandler.cs
  14. 23
      ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs
  15. 60
      ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs
  16. 7
      ErsatzTV.Application/Watermarks/Commands/DeleteWatermark.cs
  17. 45
      ErsatzTV.Application/Watermarks/Commands/DeleteWatermarkHandler.cs
  18. 24
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs
  19. 67
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs
  20. 23
      ErsatzTV.Application/Watermarks/Mapper.cs
  21. 7
      ErsatzTV.Application/Watermarks/Queries/GetAllWatermarks.cs
  22. 30
      ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksHandler.cs
  23. 7
      ErsatzTV.Application/Watermarks/Queries/GetWatermarkById.cs
  24. 29
      ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdHandler.cs
  25. 20
      ErsatzTV.Application/Watermarks/WatermarkViewModel.cs
  26. 12
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  27. 2
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  28. 92
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  29. 5
      ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs
  30. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  31. 2941
      ErsatzTV.Infrastructure/Migrations/20210614095655_Add_ChannelWatermarkNameImage.Designer.cs
  32. 76
      ErsatzTV.Infrastructure/Migrations/20210614095655_Add_ChannelWatermarkNameImage.cs
  33. 2944
      ErsatzTV.Infrastructure/Migrations/20210614144234_Add_ChannelWatermarkImageSource.Designer.cs
  34. 24
      ErsatzTV.Infrastructure/Migrations/20210614144234_Add_ChannelWatermarkImageSource.cs
  35. 22
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  36. 196
      ErsatzTV/Pages/ChannelEditor.razor
  37. 82
      ErsatzTV/Pages/Settings.razor
  38. 238
      ErsatzTV/Pages/WatermarkEditor.razor
  39. 109
      ErsatzTV/Pages/Watermarks.razor
  40. 66
      ErsatzTV/Shared/CopyWatermarkDialog.razor
  41. 1
      ErsatzTV/Shared/MainLayout.razor
  42. 27
      ErsatzTV/Validators/ChannelEditViewModelValidator.cs
  43. 45
      ErsatzTV/Validators/WatermarkEditViewModelValidator.cs
  44. 33
      ErsatzTV/ViewModels/ChannelEditViewModel.cs
  45. 77
      ErsatzTV/ViewModels/WatermarkEditViewModel.cs

5
CHANGELOG.md

@ -6,11 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Add watermark opacity setting to allow blending with content - Add watermark opacity setting to allow blending with content
- Add global watermark and channel watermark artwork - Add global watermark setting; channel-specific watermarks have precedence over global watermarks
- Watermark precedence is: channel watermark, global watermark, channel logo
### Changed ### Changed
- Remove unused API and SDK project; may reintroduce in the future but for now they have fallen out of date - Remove unused API and SDK project; may reintroduce in the future but for now they have fallen out of date
- Rework watermarks to be separate from channels (similar to ffmpeg profiles)
- This allows easy watermark reuse across channels
### Fixed ### Fixed
- Fix crash adding or editing schedule items due to Artist with no name - Fix crash adding or editing schedule items due to Artist with no name

11
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -10,14 +10,5 @@ namespace ErsatzTV.Application.Channels
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
string Watermark, int? WatermarkId);
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds,
int WatermarkOpacity);
} }

11
ErsatzTV.Application/Channels/Commands/CreateChannel.cs

@ -13,14 +13,5 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
string Watermark, int? WatermarkId) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds,
int WatermarkOpacity) : IRequest<Either<BaseError, CreateChannelResult>>;
} }

53
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -41,9 +41,10 @@ namespace ErsatzTV.Application.Channels.Commands
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) => private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request), (ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request), await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request)) ValidatePreferredLanguage(request),
await WatermarkMustExist(dbContext, request))
.Apply( .Apply(
(name, number, ffmpegProfileId, preferredLanguageCode) => (name, number, ffmpegProfileId, preferredLanguageCode, watermarkId) =>
{ {
var artwork = new List<Artwork>(); var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo)) if (!string.IsNullOrWhiteSpace(request.Logo))
@ -58,18 +59,6 @@ namespace ErsatzTV.Application.Channels.Commands
}); });
} }
if (!string.IsNullOrWhiteSpace(request.Watermark))
{
artwork.Add(
new Artwork
{
Path = request.Watermark,
ArtworkKind = ArtworkKind.Watermark,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
var channel = new Channel(Guid.NewGuid()) var channel = new Channel(Guid.NewGuid())
{ {
Name = name, Name = name,
@ -77,25 +66,10 @@ namespace ErsatzTV.Application.Channels.Commands
FFmpegProfileId = ffmpegProfileId, FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode, StreamingMode = request.StreamingMode,
Artwork = artwork, Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode PreferredLanguageCode = preferredLanguageCode,
WatermarkId = watermarkId
}; };
if (request.WatermarkMode != ChannelWatermarkMode.None)
{
channel.Watermark = new ChannelWatermark
{
Mode = request.WatermarkMode,
Location = request.WatermarkLocation,
Size = request.WatermarkSize,
WidthPercent = request.WatermarkWidth,
HorizontalMarginPercent = request.WatermarkHorizontalMargin,
VerticalMarginPercent = request.WatermarkVerticalMargin,
FrequencyMinutes = request.WatermarkFrequencyMinutes,
DurationSeconds = request.WatermarkDurationSeconds,
Opacity = request.WatermarkOpacity
};
}
return channel; return channel;
}); });
@ -136,5 +110,22 @@ namespace ErsatzTV.Application.Channels.Commands
.Filter(c => c > 0) .Filter(c => c > 0)
.MapT(_ => createChannel.FFmpegProfileId) .MapT(_ => createChannel.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.")); .Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
private static async Task<Validation<BaseError, int?>> WatermarkMustExist(
TvContext dbContext,
CreateChannel createChannel)
{
if (createChannel.WatermarkId is null)
{
return createChannel.WatermarkId;
}
return await dbContext.ChannelWatermarks
.CountAsync(w => w.Id == createChannel.WatermarkId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => createChannel.WatermarkId)
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
}
} }
} }

11
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs

@ -14,14 +14,5 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
string Watermark, int? WatermarkId) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds,
int WatermarkOpacity) : IRequest<Either<BaseError, ChannelViewModel>>;
} }

69
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -64,74 +64,9 @@ namespace ErsatzTV.Application.Channels.Commands
c.Artwork.Add(artwork); c.Artwork.Add(artwork);
}); });
} }
if (!string.IsNullOrWhiteSpace(update.Watermark))
{
Option<Artwork> maybeWatermark =
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Watermark);
maybeWatermark.Match(
artwork =>
{
artwork.Path = update.Watermark;
artwork.DateUpdated = DateTime.UtcNow;
},
() =>
{
var artwork = new Artwork
{
Path = update.Watermark,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Watermark
};
c.Artwork.Add(artwork);
});
}
else
{
c.Artwork.RemoveAll(a => a.ArtworkKind == ArtworkKind.Watermark);
}
if (update.WatermarkMode == ChannelWatermarkMode.None)
{
if (c.Watermark != null)
{
dbContext.Remove(c.Watermark);
}
}
else
{
if (c.Watermark != null)
{
c.Watermark.Mode = update.WatermarkMode;
c.Watermark.Location = update.WatermarkLocation;
c.Watermark.Size = update.WatermarkSize;
c.Watermark.WidthPercent = update.WatermarkWidth;
c.Watermark.HorizontalMarginPercent = update.WatermarkHorizontalMargin;
c.Watermark.VerticalMarginPercent = update.WatermarkVerticalMargin;
c.Watermark.FrequencyMinutes = update.WatermarkFrequencyMinutes;
c.Watermark.DurationSeconds = update.WatermarkDurationSeconds;
c.Watermark.Opacity = update.WatermarkOpacity;
}
else
{
c.Watermark = new ChannelWatermark
{
Mode = update.WatermarkMode,
Location = update.WatermarkLocation,
Size = update.WatermarkSize,
WidthPercent = update.WatermarkWidth,
HorizontalMarginPercent = update.WatermarkHorizontalMargin,
VerticalMarginPercent = update.WatermarkVerticalMargin,
FrequencyMinutes = update.WatermarkFrequencyMinutes,
DurationSeconds = update.WatermarkDurationSeconds,
Opacity = update.WatermarkOpacity
};
}
}
c.StreamingMode = update.StreamingMode; c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return ProjectToViewModel(c); return ProjectToViewModel(c);
} }

11
ErsatzTV.Application/Channels/Mapper.cs

@ -15,16 +15,7 @@ namespace ErsatzTV.Application.Channels
GetLogo(channel), GetLogo(channel),
channel.PreferredLanguageCode, channel.PreferredLanguageCode,
channel.StreamingMode, channel.StreamingMode,
GetWatermark(channel), channel.WatermarkId);
channel.Watermark?.Mode ?? ChannelWatermarkMode.None,
channel.Watermark?.Location ?? ChannelWatermarkLocation.BottomRight,
channel.Watermark?.Size ?? ChannelWatermarkSize.Scaled,
channel.Watermark?.WidthPercent ?? 15,
channel.Watermark?.HorizontalMarginPercent ?? 5,
channel.Watermark?.VerticalMarginPercent ?? 5,
channel.Watermark?.FrequencyMinutes ?? 15,
channel.Watermark?.DurationSeconds ?? 15,
channel.Watermark?.Opacity ?? 100);
private static string GetLogo(Channel channel) => private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))

8
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -104,13 +104,15 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
ConfigElementKey.FFmpegPreferredLanguageCode, ConfigElementKey.FFmpegPreferredLanguageCode,
request.Settings.PreferredLanguageCode); request.Settings.PreferredLanguageCode);
if (!string.IsNullOrWhiteSpace(request.Settings.Watermark)) if (request.Settings.GlobalWatermarkId is not null)
{ {
await _configElementRepository.Upsert(ConfigElementKey.FFmpegWatermark, request.Settings.Watermark); await _configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalWatermarkId,
request.Settings.GlobalWatermarkId.Value);
} }
else else
{ {
await _configElementRepository.Delete(ConfigElementKey.FFmpegWatermark); await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
} }
return Unit.Default; return Unit.Default;

2
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

@ -7,6 +7,6 @@
public int DefaultFFmpegProfileId { get; set; } public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; } public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; } public bool SaveReports { get; set; }
public string Watermark { get; set; } public int? GlobalWatermarkId { get; set; }
} }
} }

14
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs

@ -26,18 +26,24 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports); await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode = Option<string> preferredLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode); await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<string> watermark = Option<int> watermark =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegWatermark); await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
return new FFmpegSettingsViewModel var result = new FFmpegSettingsViewModel
{ {
FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty), FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty),
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty), FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0), DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false), SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"), PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
Watermark = await watermark.IfNoneAsync(string.Empty)
}; };
foreach (int watermarkId in watermark)
{
result.GlobalWatermarkId = watermarkId;
}
return result;
} }
} }
} }

9
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -93,6 +93,12 @@ namespace ErsatzTV.Application.Streaming.Queries
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) .GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false));
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId)
.BindT(
watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
return Right<BaseError, Process>( return Right<BaseError, Process>(
await _ffmpegProcessService.ForPlayoutItem( await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath, ffmpegPath,
@ -101,7 +107,8 @@ namespace ErsatzTV.Application.Streaming.Queries
version, version,
playoutItemWithPath.Path, playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset, playoutItemWithPath.PlayoutItem.StartOffset,
now)); now,
maybeGlobalWatermark));
}, },
async error => async error =>
{ {

9
ErsatzTV.Application/Watermarks/Commands/CopyWatermark.cs

@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Watermarks.Commands
{
public record CopyWatermark
(int WatermarkId, string Name) : IRequest<Either<BaseError, WatermarkViewModel>>;
}

54
ErsatzTV.Application/Watermarks/Commands/CopyWatermarkHandler.cs

@ -0,0 +1,54 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks.Commands
{
public class CopyWatermarkHandler :
IRequestHandler<CopyWatermark, Either<BaseError, WatermarkViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CopyWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public Task<Either<BaseError, WatermarkViewModel>> Handle(
CopyWatermark request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(PerformCopy)
.Bind(v => v.ToEitherAsync());
private async Task<WatermarkViewModel> PerformCopy(CopyWatermark request)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
ChannelWatermark channelWatermark = await dbContext.ChannelWatermarks.FindAsync(request.WatermarkId);
PropertyValues values = dbContext.Entry(channelWatermark).CurrentValues.Clone();
values["Id"] = 0;
var clone = new ChannelWatermark();
await dbContext.AddAsync(clone);
dbContext.Entry(clone).CurrentValues.SetValues(values);
clone.Name = request.Name;
await dbContext.SaveChangesAsync();
return ProjectToViewModel(clone);
}
private static Task<Validation<BaseError, CopyWatermark>> Validate(CopyWatermark request) =>
ValidateName(request).AsTask().MapT(_ => request);
private static Validation<BaseError, string> ValidateName(CopyWatermark request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
}
}

23
ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs

@ -0,0 +1,23 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Watermarks.Commands
{
public record CreateWatermark(
string Name,
string Image,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
ChannelWatermarkLocation Location,
ChannelWatermarkSize Size,
int Width,
int HorizontalMargin,
int VerticalMargin,
int FrequencyMinutes,
int DurationSeconds,
int Opacity) : IRequest<Either<BaseError, CreateWatermarkResult>>;
public record CreateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId);
}

60
ErsatzTV.Application/Watermarks/Commands/CreateWatermarkHandler.cs

@ -0,0 +1,60 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Watermarks.Commands
{
public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<BaseError, CreateWatermarkResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, CreateWatermarkResult>> Handle(
CreateWatermark request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, ChannelWatermark> validation = Validate(request);
return await validation.Apply(profile => PersistChannelWatermark(dbContext, profile));
}
private static async Task<CreateWatermarkResult> PersistChannelWatermark(
TvContext dbContext,
ChannelWatermark watermark)
{
await dbContext.ChannelWatermarks.AddAsync(watermark);
await dbContext.SaveChangesAsync();
return new CreateWatermarkResult(watermark.Id);
}
private static Validation<BaseError, ChannelWatermark> Validate(CreateWatermark request) =>
ValidateName(request)
.Map(
_ => new ChannelWatermark
{
Name = request.Name,
Image = request.ImageSource == ChannelWatermarkImageSource.Custom ? request.Image : null,
Mode = request.Mode,
ImageSource = request.ImageSource,
Location = request.Location,
Size = request.Size,
WidthPercent = request.Width,
HorizontalMarginPercent = request.HorizontalMargin,
VerticalMarginPercent = request.VerticalMargin,
FrequencyMinutes = request.FrequencyMinutes,
DurationSeconds = request.DurationSeconds,
Opacity = request.Opacity
});
private static Validation<BaseError, string> ValidateName(CreateWatermark request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
}
}

7
ErsatzTV.Application/Watermarks/Commands/DeleteWatermark.cs

@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Watermarks.Commands
{
public record DeleteWatermark(int WatermarkId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

45
ErsatzTV.Application/Watermarks/Commands/DeleteWatermarkHandler.cs

@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Watermarks.Commands
{
public class DeleteWatermarkHandler : MediatR.IRequestHandler<DeleteWatermark, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteWatermark request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, ChannelWatermark> validation = await WatermarkMustExist(dbContext, request);
return await validation.Apply(p => DoDeletion(dbContext, p));
}
private static async Task<Unit> DoDeletion(TvContext dbContext, ChannelWatermark watermark)
{
await dbContext.Database.ExecuteSqlRawAsync(
$"UPDATE Channel SET WatermarkId = NULL WHERE WatermarkId = {watermark.Id}");
dbContext.ChannelWatermarks.Remove(watermark);
await dbContext.SaveChangesAsync();
return Unit.Default;
}
private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
TvContext dbContext,
DeleteWatermark request) =>
dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == request.WatermarkId)
.Map(o => o.ToValidation<BaseError>($"Watermark {request.WatermarkId} does not exist"));
}
}

24
ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs

@ -0,0 +1,24 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Watermarks.Commands
{
public record UpdateWatermark(
int Id,
string Name,
string Image,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
ChannelWatermarkLocation Location,
ChannelWatermarkSize Size,
int Width,
int HorizontalMargin,
int VerticalMargin,
int FrequencyMinutes,
int DurationSeconds,
int Opacity) : IRequest<Either<BaseError, UpdateWatermarkResult>>;
public record UpdateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId);
}

67
ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs

@ -0,0 +1,67 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Watermarks.Commands
{
public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<BaseError, UpdateWatermarkResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateWatermarkHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, UpdateWatermarkResult>> Handle(
UpdateWatermark request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}
private static async Task<UpdateWatermarkResult> ApplyUpdateRequest(
TvContext dbContext,
ChannelWatermark p,
UpdateWatermark update)
{
p.Name = update.Name;
p.Image = update.ImageSource == ChannelWatermarkImageSource.Custom ? update.Image : null;
p.Mode = update.Mode;
p.ImageSource = update.ImageSource;
p.Location = update.Location;
p.Size = update.Size;
p.WidthPercent = update.Width;
p.HorizontalMarginPercent = update.HorizontalMargin;
p.VerticalMarginPercent = update.VerticalMargin;
p.FrequencyMinutes = update.FrequencyMinutes;
p.DurationSeconds = update.DurationSeconds;
p.Opacity = update.Opacity;
await dbContext.SaveChangesAsync();
return new UpdateWatermarkResult(p.Id);
}
private static async Task<Validation<BaseError, ChannelWatermark>> Validate(
TvContext dbContext,
UpdateWatermark request) =>
(await WatermarkMustExist(dbContext, request), ValidateName(request))
.Apply((watermark, _) => watermark);
private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
TvContext dbContext,
UpdateWatermark updateWatermark) =>
dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == updateWatermark.Id)
.Map(o => o.ToValidation<BaseError>("Watermark does not exist."));
private static Validation<BaseError, string> ValidateName(UpdateWatermark updateWatermark) =>
updateWatermark.NotEmpty(x => x.Name)
.Bind(_ => updateWatermark.NotLongerThan(50)(x => x.Name));
}
}

23
ErsatzTV.Application/Watermarks/Mapper.cs

@ -0,0 +1,23 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks
{
internal static class Mapper
{
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
watermark.Image,
watermark.Name,
watermark.Mode,
watermark.ImageSource,
watermark.Location,
watermark.Size,
watermark.WidthPercent,
watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent,
watermark.FrequencyMinutes,
watermark.DurationSeconds,
watermark.Opacity);
}
}

7
ErsatzTV.Application/Watermarks/Queries/GetAllWatermarks.cs

@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Watermarks.Queries
{
public record GetAllWatermarks : IRequest<List<WatermarkViewModel>>;
}

30
ErsatzTV.Application/Watermarks/Queries/GetAllWatermarksHandler.cs

@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks.Queries
{
public class GetAllWatermarksHandler : IRequestHandler<GetAllWatermarks, List<WatermarkViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllWatermarksHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<WatermarkViewModel>> Handle(
GetAllWatermarks request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ChannelWatermarks
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}
}

7
ErsatzTV.Application/Watermarks/Queries/GetWatermarkById.cs

@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Watermarks.Queries
{
public record GetWatermarkById(int Id) : IRequest<Option<WatermarkViewModel>>;
}

29
ErsatzTV.Application/Watermarks/Queries/GetWatermarkByIdHandler.cs

@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks.Queries
{
public class GetWatermarkByIdHandler : IRequestHandler<GetWatermarkById, Option<WatermarkViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetWatermarkByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<WatermarkViewModel>> Handle(
GetWatermarkById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == request.Id)
.MapT(ProjectToViewModel);
}
}
}

20
ErsatzTV.Application/Watermarks/WatermarkViewModel.cs

@ -0,0 +1,20 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks
{
public record WatermarkViewModel(
int Id,
string Image,
string Name,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
ChannelWatermarkLocation Location,
ChannelWatermarkSize Size,
int Width,
int HorizontalMargin,
int VerticalMargin,
int FrequencyMinutes,
int DurationSeconds,
int Opacity
);
}

12
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -3,10 +3,12 @@
public class ChannelWatermark public class ChannelWatermark
{ {
public int Id { get; set; } public int Id { get; set; }
public Channel Channel { get; set; } public string Name { get; set; }
public ChannelWatermarkMode Mode { get; set; }
public ChannelWatermarkImageSource ImageSource { get; set; }
public string Image { get; set; }
public ChannelWatermarkLocation Location { get; set; } public ChannelWatermarkLocation Location { get; set; }
public ChannelWatermarkSize Size { get; set; } public ChannelWatermarkSize Size { get; set; }
public ChannelWatermarkMode Mode { get; set; }
public int WidthPercent { get; set; } public int WidthPercent { get; set; }
public int HorizontalMarginPercent { get; set; } public int HorizontalMarginPercent { get; set; }
public int VerticalMarginPercent { get; set; } public int VerticalMarginPercent { get; set; }
@ -35,4 +37,10 @@
Permanent = 1, Permanent = 1,
Intermittent = 2 Intermittent = 2
} }
public enum ChannelWatermarkImageSource
{
Custom = 0,
ChannelLogo = 1
}
} }

2
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -12,7 +12,7 @@
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id"); public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports"); public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code"); public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegWatermark => new("ffmpeg.watermark"); public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey SearchIndexVersion => new("search_index.version"); public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count"); public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size"); public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");

92
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -5,7 +5,6 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -15,16 +14,13 @@ namespace ErsatzTV.Core.FFmpeg
{ {
private readonly IFFmpegStreamSelector _ffmpegStreamSelector; private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache; private readonly IImageCache _imageCache;
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService( public FFmpegProcessService(
IConfigElementRepository configElementRepository,
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService, FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector, IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache) IImageCache imageCache)
{ {
_configElementRepository = configElementRepository;
_playbackSettingsCalculator = ffmpegPlaybackSettingsService; _playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector; _ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache; _imageCache = imageCache;
@ -37,7 +33,8 @@ namespace ErsatzTV.Core.FFmpeg
MediaVersion version, MediaVersion version,
string path, string path,
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset now) DateTimeOffset now,
Option<ChannelWatermark> globalWatermark)
{ {
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version); MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version); Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
@ -51,40 +48,13 @@ namespace ErsatzTV.Core.FFmpeg
start, start,
now); now);
Option<string> maybeWatermarkPath = None; (Option<ChannelWatermark> maybeWatermark, Option<string> maybeWatermarkPath) =
GetWatermarkOptions(channel, globalWatermark);
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
{
// check for channel watermark
maybeWatermarkPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Watermark)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Watermark, Option<int>.None));
// check for global watermark
if (maybeWatermarkPath.IsNone)
{
maybeWatermarkPath = await _configElementRepository
.GetValue<string>(ConfigElementKey.FFmpegWatermark)
.MapT(a => _imageCache.GetPathForImage(a, ArtworkKind.Watermark, Option<int>.None));
}
// finally, check for channel logo
if (maybeWatermarkPath.IsNone)
{
maybeWatermarkPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
}
}
bool isAnimated = await maybeWatermarkPath.Match( bool isAnimated = await maybeWatermarkPath.Match(
p => _imageCache.IsAnimated(p), p => _imageCache.IsAnimated(p),
() => Task.FromResult(false)); () => Task.FromResult(false));
Option<ChannelWatermark> maybeWatermark = channel.Watermark;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
.WithThreads(playbackSettings.ThreadCount) .WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration) .WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
@ -187,5 +157,59 @@ namespace ErsatzTV.Core.FFmpeg
private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) => private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
displaySize.Width != target.Width || displaySize.Height != target.Height; displaySize.Width != target.Width || displaySize.Height != target.Height;
private WatermarkOptions GetWatermarkOptions(Channel channel, Option<ChannelWatermark> globalWatermark)
{
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
{
// check for channel watermark
if (channel.Watermark != null)
{
switch (channel.Watermark.ImageSource)
{
case ChannelWatermarkImageSource.Custom:
string customPath = _imageCache.GetPathForImage(
channel.Watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(channel.Watermark, customPath);
case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(channel.Watermark, maybeChannelPath);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
// check for global watermark
foreach (ChannelWatermark watermark in globalWatermark)
{
switch (watermark.ImageSource)
{
case ChannelWatermarkImageSource.Custom:
string customPath = _imageCache.GetPathForImage(
watermark.Image,
ArtworkKind.Watermark,
Option<int>.None);
return new WatermarkOptions(watermark, customPath);
case ChannelWatermarkImageSource.ChannelLogo:
Option<string> maybeChannelPath = channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(watermark, maybeChannelPath);
default:
throw new NotSupportedException("Unsupported watermark image source");
}
}
}
return new WatermarkOptions(None, None);
}
private record WatermarkOptions(Option<ChannelWatermark> Watermark, Option<string> ImagePath);
} }
} }

5
ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs

@ -21,11 +21,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(c => c.Artwork) builder.HasMany(c => c.Artwork)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.Watermark)
.WithOne(w => w.Channel)
.HasForeignKey<Channel>(c => c.WatermarkId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
} }

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -14,6 +14,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<ConfigElement> ConfigElements { get; set; } public DbSet<ConfigElement> ConfigElements { get; set; }
public DbSet<Channel> Channels { get; set; } public DbSet<Channel> Channels { get; set; }
public DbSet<ChannelWatermark> ChannelWatermarks { get; set; }
public DbSet<MediaSource> MediaSources { get; set; } public DbSet<MediaSource> MediaSources { get; set; }
public DbSet<LocalMediaSource> LocalMediaSources { get; set; } public DbSet<LocalMediaSource> LocalMediaSources { get; set; }
public DbSet<PlexMediaSource> PlexMediaSources { get; set; } public DbSet<PlexMediaSource> PlexMediaSources { get; set; }

2941
ErsatzTV.Infrastructure/Migrations/20210614095655_Add_ChannelWatermarkNameImage.Designer.cs generated

File diff suppressed because it is too large Load Diff

76
ErsatzTV.Infrastructure/Migrations/20210614095655_Add_ChannelWatermarkNameImage.cs

@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ChannelWatermarkNameImage : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel");
migrationBuilder.DropIndex(
name: "IX_Channel_WatermarkId",
table: "Channel");
migrationBuilder.AddColumn<string>(
name: "Image",
table: "ChannelWatermark",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Name",
table: "ChannelWatermark",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Channel_WatermarkId",
table: "Channel",
column: "WatermarkId");
migrationBuilder.AddForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel");
migrationBuilder.DropIndex(
name: "IX_Channel_WatermarkId",
table: "Channel");
migrationBuilder.DropColumn(
name: "Image",
table: "ChannelWatermark");
migrationBuilder.DropColumn(
name: "Name",
table: "ChannelWatermark");
migrationBuilder.CreateIndex(
name: "IX_Channel_WatermarkId",
table: "Channel",
column: "WatermarkId",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

2944
ErsatzTV.Infrastructure/Migrations/20210614144234_Add_ChannelWatermarkImageSource.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure/Migrations/20210614144234_Add_ChannelWatermarkImageSource.cs

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ChannelWatermarkImageSource : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ImageSource",
table: "ChannelWatermark",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImageSource",
table: "ChannelWatermark");
}
}
}

22
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -213,8 +213,7 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("Number") b.HasIndex("Number")
.IsUnique(); .IsUnique();
b.HasIndex("WatermarkId") b.HasIndex("WatermarkId");
.IsUnique();
b.ToTable("Channel"); b.ToTable("Channel");
}); });
@ -234,12 +233,21 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("HorizontalMarginPercent") b.Property<int>("HorizontalMarginPercent")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<int>("ImageSource")
.HasColumnType("INTEGER");
b.Property<int>("Location") b.Property<int>("Location")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("Mode") b.Property<int>("Mode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Opacity") b.Property<int>("Opacity")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -1845,9 +1853,8 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired(); .IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark") b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithOne("Channel") .WithMany()
.HasForeignKey("ErsatzTV.Core.Domain.Channel", "WatermarkId") .HasForeignKey("WatermarkId");
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("FFmpegProfile"); b.Navigation("FFmpegProfile");
@ -2736,11 +2743,6 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playouts"); b.Navigation("Playouts");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("Channel");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{ {
b.Navigation("CollectionItems"); b.Navigation("CollectionItems");

196
ErsatzTV/Pages/ChannelEditor.razor

@ -8,6 +8,8 @@
@using System.Globalization @using System.Globalization
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Channels.Queries @using ErsatzTV.Application.Channels.Queries
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries
@inject NavigationManager _navigationManager @inject NavigationManager _navigationManager
@inject ILogger<ChannelEditor> _logger @inject ILogger<ChannelEditor> _logger
@inject ISnackbar _snackbar @inject ISnackbar _snackbar
@ -15,14 +17,12 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;"> <div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync"> <EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/> <FluentValidator/>
<MudCard> <MudCard>
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>
<MudText Typo="Typo.h6">Channel Settings</MudText> <MudText Typo="Typo.h5">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
</CardHeaderContent> </CardHeaderContent>
</MudCardHeader> </MudCardHeader>
<MudCardContent> <MudCardContent>
@ -65,124 +65,14 @@
</MudButton> </MudButton>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<div style="padding-bottom: 16px; padding-top: 20px;"> <MudSelect Class="mt-3" Label="Watermark" @bind-Value="_model.WatermarkId" For="@(() => _model.WatermarkId)"
<MudText Typo="Typo.h6">Watermark Settings</MudText>
</div>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<InputFile id="watermarkFileInput" OnChange="UploadWatermark" hidden/>
@if (!string.IsNullOrWhiteSpace(_model.Watermark))
{
<MudElement HtmlTag="img" src="@($"artwork/watermarks/{_model.Watermark}")" Style="max-height: 50px"/>
}
</MudItem>
<MudItem xs="6">
@if (string.IsNullOrWhiteSpace(_model.Watermark))
{
<MudButton Class="ml-auto" HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
for="watermarkFileInput">
Upload Watermark
</MudButton>
}
else
{
<MudButton Class="ml-auto" HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.DeleteForever"
OnClick="DeleteWatermark">
Delete Watermark
</MudButton>
}
</MudItem>
</MudGrid>
<MudSelect Class="mt-3" Label="Mode" @bind-Value="_model.WatermarkMode"
For="@(() => _model.WatermarkMode)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)"> Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">
<MudSelectItem Value="@(ChannelWatermarkMode.None)">None</MudSelectItem> <MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Permanent)">Permanent</MudSelectItem> @foreach (WatermarkViewModel watermark in _watermarks)
<MudSelectItem Value="@(ChannelWatermarkMode.Intermittent)">Intermittent</MudSelectItem> {
</MudSelect> <MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
<MudSelect Class="mt-3" Label="Location" @bind-Value="_model.WatermarkLocation" }
For="@(() => _model.WatermarkLocation)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomRight)">Bottom Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomLeft)">Bottom Left</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopRight)">Top Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopLeft)">Top Left</MudSelectItem>
</MudSelect> </MudSelect>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudSelect Label="Size" @bind-Value="_model.WatermarkSize"
For="@(() => _model.WatermarkSize)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkSize.Scaled)">Scaled</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkSize.ActualSize)">Actual Size</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="6">
<MudTextField Label="Width" @bind-Value="_model.WatermarkWidth"
For="@(() => _model.WatermarkWidth)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode == ChannelWatermarkMode.None || _model.WatermarkSize == ChannelWatermarkSize.ActualSize)"
Immediate="true"/>
</MudItem>
</MudGrid>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudTextField Label="Horizontal Margin" @bind-Value="_model.WatermarkHorizontalMargin"
For="@(() => _model.WatermarkHorizontalMargin)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode == ChannelWatermarkMode.None)"
Immediate="true"/>
</MudItem>
<MudItem xs="6">
<MudTextField Label="Vertical Margin" @bind-Value="_model.WatermarkVerticalMargin"
For="@(() => _model.WatermarkVerticalMargin)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode == ChannelWatermarkMode.None)"
Immediate="true"/>
</MudItem>
</MudGrid>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudSelect Label="Frequency" @bind-Value="_model.WatermarkFrequencyMinutes"
For="@(() => _model.WatermarkFrequencyMinutes)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode != ChannelWatermarkMode.Intermittent)">
<MudSelectItem Value="5">5 minutes</MudSelectItem>
<MudSelectItem Value="10">10 minutes</MudSelectItem>
<MudSelectItem Value="15">15 minutes</MudSelectItem>
<MudSelectItem Value="20">20 minutes</MudSelectItem>
<MudSelectItem Value="30">30 minutes</MudSelectItem>
<MudSelectItem Value="60">60 minutes</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="6">
<MudTextField Label="Duration" @bind-Value="_model.WatermarkDurationSeconds"
For="@(() => _model.WatermarkDurationSeconds)"
Adornment="Adornment.End"
AdornmentText="seconds"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode != ChannelWatermarkMode.Intermittent)"
Immediate="true"/>
</MudItem>
</MudGrid>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudTextField Label="Opacity" @bind-Value="_model.WatermarkOpacity"
For="@(() => _model.WatermarkOpacity)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect || _model.WatermarkMode == ChannelWatermarkMode.None)"
Immediate="true"/>
</MudItem>
<MudItem xs="6" />
</MudGrid>
</MudCardContent> </MudCardContent>
<MudCardActions> <MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary"> <MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -205,11 +95,13 @@
private List<FFmpegProfileViewModel> _ffmpegProfiles; private List<FFmpegProfileViewModel> _ffmpegProfiles;
private List<CultureInfo> _availableCultures; private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
await LoadFFmpegProfilesAsync(); await LoadFFmpegProfiles();
_availableCultures = await _mediator.Send(new GetAllLanguageCodes()); _availableCultures = await _mediator.Send(new GetAllLanguageCodes());
await LoadWatermarks();
if (Id.HasValue) if (Id.HasValue)
{ {
@ -224,16 +116,7 @@
_model.Logo = channelViewModel.Logo; _model.Logo = channelViewModel.Logo;
_model.StreamingMode = channelViewModel.StreamingMode; _model.StreamingMode = channelViewModel.StreamingMode;
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode; _model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
_model.WatermarkMode = channelViewModel.WatermarkMode; _model.WatermarkId = channelViewModel.WatermarkId;
_model.Watermark = channelViewModel.Watermark;
_model.WatermarkLocation = channelViewModel.WatermarkLocation;
_model.WatermarkSize = channelViewModel.WatermarkSize;
_model.WatermarkWidth = channelViewModel.WatermarkWidth;
_model.WatermarkHorizontalMargin = channelViewModel.WatermarkHorizontalMargin;
_model.WatermarkVerticalMargin = channelViewModel.WatermarkVerticalMargin;
_model.WatermarkFrequencyMinutes = channelViewModel.WatermarkFrequencyMinutes;
_model.WatermarkDurationSeconds = channelViewModel.WatermarkDurationSeconds;
_model.WatermarkOpacity = channelViewModel.WatermarkOpacity;
}, },
() => _navigationManager.NavigateTo("404")); () => _navigationManager.NavigateTo("404"));
} }
@ -249,16 +132,6 @@
_model.Name = "New Channel"; _model.Name = "New Channel";
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId; _model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_model.StreamingMode = StreamingMode.TransportStream; _model.StreamingMode = StreamingMode.TransportStream;
_model.WatermarkMode = ChannelWatermarkMode.None;
_model.Watermark = string.Empty;
_model.WatermarkLocation = ChannelWatermarkLocation.BottomRight;
_model.WatermarkSize = ChannelWatermarkSize.Scaled;
_model.WatermarkWidth = 15;
_model.WatermarkHorizontalMargin = 5;
_model.WatermarkVerticalMargin = 5;
_model.WatermarkFrequencyMinutes = 15;
_model.WatermarkDurationSeconds = 15;
_model.WatermarkOpacity = 100;
} }
} }
@ -270,9 +143,12 @@
private bool IsEdit => Id.HasValue; private bool IsEdit => Id.HasValue;
private async Task LoadFFmpegProfilesAsync() => private async Task LoadFFmpegProfiles() =>
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles()); _ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles());
private async Task LoadWatermarks() =>
_watermarks = await _mediator.Send(new GetAllWatermarks());
private async Task HandleSubmitAsync() private async Task HandleSubmitAsync()
{ {
_messageStore.Clear(); _messageStore.Clear();
@ -323,42 +199,4 @@
} }
} }
private async Task UploadWatermark(InputFileChangeEventArgs e)
{
try
{
var buffer = new byte[e.File.Size];
await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer);
Either<BaseError, string> maybeCacheFileName = await _mediator
.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Watermark));
maybeCacheFileName.Match(
relativeFileName =>
{
_model.Watermark = relativeFileName;
StateHasChanged();
},
error =>
{
_snackbar.Add($"Unexpected error saving watermark: {error.Value}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", error.Value);
});
}
catch (IOException)
{
_snackbar.Add("Watermark exceeds maximum allowed file size of 10 MB", Severity.Error);
_logger.LogError("Watermark exceeds maximum allowed file size of 10 MB");
}
catch (Exception ex)
{
_snackbar.Add($"Unexpected error saving watermark: {ex.Message}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", ex.Message);
}
}
private void DeleteWatermark()
{
_model.Watermark = null;
StateHasChanged();
}
} }

82
ErsatzTV/Pages/Settings.razor

@ -9,7 +9,9 @@
@using ErsatzTV.Application.Configuration.Queries @using ErsatzTV.Application.Configuration.Queries
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@using ErsatzTV.Application.Configuration.Commands @using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Application.Images.Commands @using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries
@using Microsoft.AspNetCore.Components
@inject IMediator _mediator @inject IMediator _mediator
@inject ISnackbar _snackbar @inject ISnackbar _snackbar
@inject ILogger<Settings> _logger @inject ILogger<Settings> _logger
@ -47,37 +49,13 @@
Color="Color.Primary" Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/> @bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement> </MudElement>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center"> <MudSelect Class="mt-3" Label="Global Watermark" @bind-Value="_ffmpegSettings.GlobalWatermarkId" For="@(() => _ffmpegSettings.GlobalWatermarkId)">
<MudItem xs="6"> <MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
<InputFile id="fileInput" OnChange="UploadWatermark" hidden/> @foreach (WatermarkViewModel watermark in _watermarks)
@if (!string.IsNullOrWhiteSpace(_ffmpegSettings.Watermark)) {
{ <MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
<MudElement HtmlTag="img" src="@($"artwork/watermarks/{_ffmpegSettings.Watermark}")" Style="max-height: 50px"/> }
} </MudSelect>
</MudItem>
<MudItem xs="6">
@if (string.IsNullOrWhiteSpace(_ffmpegSettings.Watermark))
{
<MudButton Class="ml-auto" HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
for="fileInput">
Upload Watermark
</MudButton>
}
else
{
<MudButton Class="ml-auto" HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.DeleteForever"
OnClick="DeleteWatermark">
Delete Watermark
</MudButton>
}
</MudItem>
</MudGrid>
</MudForm> </MudForm>
</MudCardContent> </MudCardContent>
<MudCardActions> <MudCardActions>
@ -130,6 +108,7 @@
private List<FFmpegProfileViewModel> _ffmpegProfiles; private List<FFmpegProfileViewModel> _ffmpegProfiles;
private FFmpegSettingsViewModel _ffmpegSettings; private FFmpegSettingsViewModel _ffmpegSettings;
private List<CultureInfo> _availableCultures; private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks;
private int _tunerCount; private int _tunerCount;
private int _libraryRefreshInterval; private int _libraryRefreshInterval;
@ -140,6 +119,7 @@
_ffmpegSettings = await _mediator.Send(new GetFFmpegSettings()); _ffmpegSettings = await _mediator.Send(new GetFFmpegSettings());
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath); _success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await _mediator.Send(new GetAllLanguageCodes()); _availableCultures = await _mediator.Send(new GetAllLanguageCodes());
_watermarks = await _mediator.Send(new GetAllWatermarks());
_tunerCount = await _mediator.Send(new GetHDHRTunerCount()); _tunerCount = await _mediator.Send(new GetHDHRTunerCount());
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount)); _hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
_libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval()); _libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval());
@ -190,43 +170,5 @@
}, },
Right: _ => _snackbar.Add("Successfully saved scanner settings", Severity.Success)); Right: _ => _snackbar.Add("Successfully saved scanner settings", Severity.Success));
} }
private async Task UploadWatermark(InputFileChangeEventArgs e)
{
try
{
var buffer = new byte[e.File.Size];
await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer);
Either<BaseError, string> maybeCacheFileName = await _mediator
.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Watermark));
maybeCacheFileName.Match(
relativeFileName =>
{
_ffmpegSettings.Watermark = relativeFileName;
StateHasChanged();
},
error =>
{
_snackbar.Add($"Unexpected error saving watermark: {error.Value}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", error.Value);
});
}
catch (IOException)
{
_snackbar.Add("Watermark exceeds maximum allowed file size of 10 MB", Severity.Error);
_logger.LogError("Watermark exceeds maximum allowed file size of 10 MB");
}
catch (Exception ex)
{
_snackbar.Add($"Unexpected error saving watermark: {ex.Message}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", ex.Message);
}
}
private void DeleteWatermark()
{
_ffmpegSettings.Watermark = null;
StateHasChanged();
}
} }

238
ErsatzTV/Pages/WatermarkEditor.razor

@ -0,0 +1,238 @@
@page "/watermarks/{Id:int}"
@page "/watermarks/add"
@using ErsatzTV.Application.Images.Commands
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries
@inject NavigationManager _navigationManager
@inject ILogger<WatermarkEditor> _logger
@inject ISnackbar _snackbar
@inject IMediator _mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">@(IsEdit ? "Edit Watermark" : "Add Watermark")</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Mode" @bind-Value="_model.Mode"
For="@(() => _model.Mode)">
<MudSelectItem Value="@(ChannelWatermarkMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Permanent)">Permanent</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Intermittent)">Intermittent</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="Image Source" @bind-Value="_model.ImageSource"
For="@(() => _model.ImageSource)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkImageSource.Custom)">Custom</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkImageSource.ChannelLogo)">Channel Logo</MudSelectItem>
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<InputFile id="watermarkFileInput" OnChange="UploadWatermark" style="display: none;"/>
@if (!string.IsNullOrWhiteSpace(_model.Image) && _model.ImageSource != ChannelWatermarkImageSource.ChannelLogo)
{
<MudElement HtmlTag="img" src="@($"artwork/watermarks/{_model.Image}")" Style="max-height: 50px"/>
}
<ValidationMessage For="@(() => _model.Image)" style="color: #f44336!important;"/>
</MudItem>
<MudItem xs="6">
<MudButton Class="ml-auto" HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
Disabled="@(_model.ImageSource == ChannelWatermarkImageSource.ChannelLogo)"
for="watermarkFileInput">
Upload Image
</MudButton>
</MudItem>
</MudGrid>
<MudSelect Class="mt-3" Label="Location" @bind-Value="_model.Location"
For="@(() => _model.Location)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomRight)">Bottom Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomLeft)">Bottom Left</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopRight)">Top Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopLeft)">Top Left</MudSelectItem>
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudSelect Label="Size" @bind-Value="_model.Size"
For="@(() => _model.Size)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkSize.Scaled)">Scaled</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkSize.ActualSize)">Actual Size</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="6">
<MudTextField Label="Width" @bind-Value="_model.Width"
For="@(() => _model.Width)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.Mode == ChannelWatermarkMode.None || _model.Size == ChannelWatermarkSize.ActualSize)"
Immediate="true"/>
</MudItem>
</MudGrid>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudTextField Label="Horizontal Margin" @bind-Value="_model.HorizontalMargin"
For="@(() => _model.HorizontalMargin)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)"
Immediate="true"/>
</MudItem>
<MudItem xs="6">
<MudTextField Label="Vertical Margin" @bind-Value="_model.VerticalMargin"
For="@(() => _model.VerticalMargin)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)"
Immediate="true"/>
</MudItem>
</MudGrid>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudSelect Label="Frequency" @bind-Value="_model.FrequencyMinutes"
For="@(() => _model.FrequencyMinutes)"
Disabled="@(_model.Mode != ChannelWatermarkMode.Intermittent)">
<MudSelectItem Value="5">5 minutes</MudSelectItem>
<MudSelectItem Value="10">10 minutes</MudSelectItem>
<MudSelectItem Value="15">15 minutes</MudSelectItem>
<MudSelectItem Value="20">20 minutes</MudSelectItem>
<MudSelectItem Value="30">30 minutes</MudSelectItem>
<MudSelectItem Value="60">60 minutes</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="6">
<MudTextField Label="Duration" @bind-Value="_model.DurationSeconds"
For="@(() => _model.DurationSeconds)"
Adornment="Adornment.End"
AdornmentText="seconds"
Disabled="@(_model.Mode != ChannelWatermarkMode.Intermittent)"
Immediate="true"/>
</MudItem>
</MudGrid>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudTextField Label="Opacity" @bind-Value="_model.Opacity"
For="@(() => _model.Opacity)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)"
Immediate="true"/>
</MudItem>
<MudItem xs="6" />
</MudGrid>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@(IsEdit ? "Save Changes" : "Add Watermark")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
</MudContainer>
@code {
[Parameter]
public int Id { get; set; }
private WatermarkEditViewModel _model = new();
private EditContext _editContext;
private ValidationMessageStore _messageStore;
protected override async Task OnParametersSetAsync()
{
if (IsEdit)
{
Option<WatermarkViewModel> watermark = await _mediator.Send(new GetWatermarkById(Id));
watermark.Match(
watermarkViewModel => _model = new WatermarkEditViewModel(watermarkViewModel),
() => _navigationManager.NavigateTo("404"));
}
else
{
_model = new WatermarkEditViewModel
{
Name = string.Empty,
Mode = ChannelWatermarkMode.Permanent,
Image = string.Empty,
Location = ChannelWatermarkLocation.BottomRight,
Size = ChannelWatermarkSize.Scaled,
Width = 15,
HorizontalMargin = 5,
VerticalMargin = 5,
FrequencyMinutes = 15,
DurationSeconds = 15,
Opacity = 100,
};
}
_editContext = new EditContext(_model);
_messageStore = new ValidationMessageStore(_editContext);
}
private bool IsEdit => Id != 0;
private async Task HandleSubmitAsync()
{
_messageStore.Clear();
if (_editContext.Validate())
{
Seq<BaseError> errorMessage = IsEdit ?
(await _mediator.Send(_model.ToUpdate())).LeftToSeq() :
(await _mediator.Send(_model.ToCreate())).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
_snackbar.Add("Unexpected error saving watermark");
_logger.LogError("Unexpected error saving watermark: {Error}", error.Value);
},
() => _navigationManager.NavigateTo("/watermarks"));
}
}
private async Task UploadWatermark(InputFileChangeEventArgs e)
{
try
{
var buffer = new byte[e.File.Size];
await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer);
Either<BaseError, string> maybeCacheFileName = await _mediator
.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Watermark));
maybeCacheFileName.Match(
relativeFileName =>
{
_model.Image = relativeFileName;
_messageStore.Clear();
_editContext.Validate();
StateHasChanged();
},
error =>
{
_snackbar.Add($"Unexpected error saving watermark: {error.Value}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", error.Value);
});
}
catch (IOException)
{
_snackbar.Add("Watermark exceeds maximum allowed file size of 10 MB", Severity.Error);
_logger.LogError("Watermark exceeds maximum allowed file size of 10 MB");
}
catch (Exception ex)
{
_snackbar.Add($"Unexpected error saving watermark: {ex.Message}", Severity.Error);
_logger.LogError("Unexpected error saving watermark: {Error}", ex.Message);
}
}
}

109
ErsatzTV/Pages/Watermarks.razor

@ -0,0 +1,109 @@
@page "/watermarks"
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Commands
@using ErsatzTV.Application.Watermarks.Queries
@inject IDialogService _dialog
@inject IMediator _mediator
@inject ILogger<Watermarks> _logger
@inject ISnackbar _snackbar
@inject NavigationManager _navigationManager
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_watermarks">
<ToolBarContent>
<MudText Typo="Typo.h6">Watermarks</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col/>
<col/>
<col style="width: 180px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Image</MudTh>
<MudTh>Mode</MudTh>
<MudTh>Location</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Image">
@if (!string.IsNullOrWhiteSpace(context.Image))
{
<MudElement HtmlTag="img" src="@($"artwork/watermarks/{context.Image}")" Style="max-height: 50px"/>
}
else if (context.ImageSource == ChannelWatermarkImageSource.ChannelLogo)
{
<MudText>[channel logo]</MudText>
}
</MudTd>
<MudTd DataLabel="Mode">
@context.Mode
</MudTd>
<MudTd DataLabel="Location">
@context.Location
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Watermark">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/watermarks/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Copy Watermark">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyWatermarkAsync(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Watermark">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteWatermarkAsync(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/watermarks/add" Class="mt-4">
Add Watermark
</MudButton>
</MudContainer>
@code {
private List<WatermarkViewModel> _watermarks;
protected override async Task OnParametersSetAsync() => await LoadWatermarksAsync();
private async Task LoadWatermarksAsync() =>
_watermarks = await _mediator.Send(new GetAllWatermarks());
private async Task DeleteWatermarkAsync(WatermarkViewModel watermark)
{
var parameters = new DialogParameters { { "EntityType", "watermark" }, { "EntityName", watermark.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Watermark", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await _mediator.Send(new DeleteWatermark(watermark.Id));
await LoadWatermarksAsync();
}
}
private async Task CopyWatermarkAsync(WatermarkViewModel watermark)
{
var parameters = new DialogParameters { { "WatermarkId", watermark.Id } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<CopyWatermarkDialog>("Copy Watermark", parameters, options);
DialogResult dialogResult = await dialog.Result;
if (!dialogResult.Cancelled && dialogResult.Data is WatermarkViewModel data)
{
_navigationManager.NavigateTo($"/watermarks/{data.Id}");
}
}
}

66
ErsatzTV/Shared/CopyWatermarkDialog.razor

@ -0,0 +1,66 @@
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Commands
@inject IMediator _mediator
@inject ISnackbar _snackbar
@inject ILogger<AddToCollectionDialog> _logger
<MudDialog>
<DialogContent>
<EditForm Model="@_dummyModel" OnSubmit="@(_ => Submit())">
<MudContainer Class="mb-6">
<MudText>
Enter a name for the new Watermark
</MudText>
</MudContainer>
<MudTextFieldString Label="New Watermark Name"
@bind-Text="@_newWatermarkName"
Class="mb-6 mx-4">
</MudTextFieldString>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
Copy Watermark
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
MudDialogInstance MudDialog { get; set; }
[Parameter]
public int WatermarkId { get; set; }
private record DummyModel;
private readonly DummyModel _dummyModel = new();
private string _newWatermarkName;
private bool CanSubmit() => !string.IsNullOrWhiteSpace(_newWatermarkName);
private async Task Submit()
{
if (!CanSubmit())
{
return;
}
Either<BaseError, WatermarkViewModel> maybeResult =
await _mediator.Send(new CopyWatermark(WatermarkId, _newWatermarkName));
maybeResult.Match(
watermark => { MudDialog.Close(DialogResult.Ok(watermark)); },
error =>
{
_snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Error copying Watermark: {Error}", error.Value);
MudDialog.Close(DialogResult.Cancel());
});
}
private void Cancel(MouseEventArgs e) => MudDialog.Cancel();
}

1
ErsatzTV/Shared/MainLayout.razor

@ -43,6 +43,7 @@
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/channels">Channels</MudNavLink> <MudNavLink Href="/channels">Channels</MudNavLink>
<MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink> <MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink>
<MudNavLink Href="/watermarks">Watermarks</MudNavLink>
<MudNavGroup Title="Media Sources" Expanded="true"> <MudNavGroup Title="Media Sources" Expanded="true">
<MudNavLink Href="/media/emby">Emby</MudNavLink> <MudNavLink Href="/media/emby">Emby</MudNavLink>
<MudNavLink Href="/media/jellyfin">Jellyfin</MudNavLink> <MudNavLink Href="/media/jellyfin">Jellyfin</MudNavLink>

27
ErsatzTV/Validators/ChannelEditViewModelValidator.cs

@ -27,33 +27,6 @@ namespace ErsatzTV.Validators
StringComparison.OrdinalIgnoreCase))) StringComparison.OrdinalIgnoreCase)))
.When(vm => !string.IsNullOrWhiteSpace(vm.PreferredLanguageCode)) .When(vm => !string.IsNullOrWhiteSpace(vm.PreferredLanguageCode))
.WithMessage("Preferred language code is invalid"); .WithMessage("Preferred language code is invalid");
RuleFor(x => x.WatermarkWidth)
.GreaterThan(0)
.LessThanOrEqualTo(100)
.When(
vm => vm.WatermarkMode != ChannelWatermarkMode.None &&
vm.WatermarkSize == ChannelWatermarkSize.Scaled);
RuleFor(x => x.WatermarkHorizontalMargin)
.GreaterThanOrEqualTo(0)
.LessThanOrEqualTo(50)
.When(vm => vm.WatermarkMode != ChannelWatermarkMode.None);
RuleFor(x => x.WatermarkVerticalMargin)
.GreaterThanOrEqualTo(0)
.LessThanOrEqualTo(50)
.When(vm => vm.WatermarkMode != ChannelWatermarkMode.None);
RuleFor(x => x.WatermarkDurationSeconds)
.GreaterThan(0)
.LessThan(c => c.WatermarkFrequencyMinutes * 60)
.When(vm => vm.WatermarkMode != ChannelWatermarkMode.None);
RuleFor(x => x.WatermarkOpacity)
.GreaterThan(0)
.LessThanOrEqualTo(100)
.When(vm => vm.WatermarkMode != ChannelWatermarkMode.None);
} }
} }
} }

45
ErsatzTV/Validators/WatermarkEditViewModelValidator.cs

@ -0,0 +1,45 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class WatermarkEditViewModelValidator : AbstractValidator<WatermarkEditViewModel>
{
public WatermarkEditViewModelValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.Image)
.NotEmpty()
.WithMessage("Watermark image is required!");
RuleFor(x => x.Width)
.GreaterThan(0)
.LessThanOrEqualTo(100)
.When(
vm => vm.Mode != ChannelWatermarkMode.None &&
vm.Size == ChannelWatermarkSize.Scaled);
RuleFor(x => x.HorizontalMargin)
.GreaterThanOrEqualTo(0)
.LessThanOrEqualTo(50)
.When(vm => vm.Mode != ChannelWatermarkMode.None);
RuleFor(x => x.VerticalMargin)
.GreaterThanOrEqualTo(0)
.LessThanOrEqualTo(50)
.When(vm => vm.Mode != ChannelWatermarkMode.None);
RuleFor(x => x.DurationSeconds)
.GreaterThan(0)
.LessThan(c => c.FrequencyMinutes * 60)
.When(vm => vm.Mode != ChannelWatermarkMode.None);
RuleFor(x => x.Opacity)
.GreaterThan(0)
.LessThanOrEqualTo(100)
.When(vm => vm.Mode != ChannelWatermarkMode.None);
}
}
}

33
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -12,16 +12,7 @@ namespace ErsatzTV.ViewModels
public string PreferredLanguageCode { get; set; } public string PreferredLanguageCode { get; set; }
public string Logo { get; set; } public string Logo { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
public string Watermark { get; set; } public int? WatermarkId { get; set; }
public ChannelWatermarkMode WatermarkMode { get; set; }
public ChannelWatermarkLocation WatermarkLocation { get; set; }
public ChannelWatermarkSize WatermarkSize { get; set; }
public int WatermarkWidth { get; set; }
public int WatermarkHorizontalMargin { get; set; }
public int WatermarkVerticalMargin { get; set; }
public int WatermarkFrequencyMinutes { get; set; }
public int WatermarkDurationSeconds { get; set; }
public int WatermarkOpacity { get; set; }
public UpdateChannel ToUpdate() => public UpdateChannel ToUpdate() =>
new( new(
@ -32,16 +23,7 @@ namespace ErsatzTV.ViewModels
Logo, Logo,
PreferredLanguageCode, PreferredLanguageCode,
StreamingMode, StreamingMode,
Watermark, WatermarkId);
WatermarkMode,
WatermarkLocation,
WatermarkSize,
WatermarkWidth,
WatermarkHorizontalMargin,
WatermarkVerticalMargin,
WatermarkFrequencyMinutes,
WatermarkDurationSeconds,
WatermarkOpacity);
public CreateChannel ToCreate() => public CreateChannel ToCreate() =>
new( new(
@ -51,15 +33,6 @@ namespace ErsatzTV.ViewModels
Logo, Logo,
PreferredLanguageCode, PreferredLanguageCode,
StreamingMode, StreamingMode,
Watermark, WatermarkId);
WatermarkMode,
WatermarkLocation,
WatermarkSize,
WatermarkWidth,
WatermarkHorizontalMargin,
WatermarkVerticalMargin,
WatermarkFrequencyMinutes,
WatermarkDurationSeconds,
WatermarkOpacity);
} }
} }

77
ErsatzTV/ViewModels/WatermarkEditViewModel.cs

@ -0,0 +1,77 @@
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Application.Watermarks.Commands;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.ViewModels
{
public class WatermarkEditViewModel
{
public WatermarkEditViewModel()
{
}
public WatermarkEditViewModel(WatermarkViewModel vm)
{
Id = vm.Id;
Name = vm.Name;
Image = vm.Image;
Mode = vm.Mode;
ImageSource = vm.ImageSource;
Location = vm.Location;
Size = vm.Size;
Width = vm.Width;
HorizontalMargin = vm.HorizontalMargin;
VerticalMargin = vm.VerticalMargin;
FrequencyMinutes = vm.FrequencyMinutes;
DurationSeconds = vm.DurationSeconds;
Opacity = vm.Opacity;
}
public int Id { get; set; }
public string Name { get; set; }
public string Image { get; set; }
public ChannelWatermarkMode Mode { get; set; }
public ChannelWatermarkImageSource ImageSource { get; set; }
public ChannelWatermarkLocation Location { get; set; }
public ChannelWatermarkSize Size { get; set; }
public int Width { get; set; }
public int HorizontalMargin { get; set; }
public int VerticalMargin { get; set; }
public int FrequencyMinutes { get; set; }
public int DurationSeconds { get; set; }
public int Opacity { get; set; }
public CreateWatermark ToCreate() =>
new(
Name,
Image,
Mode,
ImageSource,
Location,
Size,
Width,
HorizontalMargin,
VerticalMargin,
FrequencyMinutes,
DurationSeconds,
Opacity
);
public UpdateWatermark ToUpdate() =>
new(
Id,
Name,
Image,
Mode,
ImageSource,
Location,
Size,
Width,
HorizontalMargin,
VerticalMargin,
FrequencyMinutes,
DurationSeconds,
Opacity
);
}
}
Loading…
Cancel
Save