Browse Source

rework watermarks (#261)

* rework watermarks to be separate from channels

* update changelog
pull/262/head
Jason Dove 4 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/). @@ -6,11 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add watermark opacity setting to allow blending with content
- Add global watermark and channel watermark artwork
- Watermark precedence is: channel watermark, global watermark, channel logo
- Add global watermark setting; channel-specific watermarks have precedence over global watermarks
### Changed
- 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
- 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 @@ -10,14 +10,5 @@ namespace ErsatzTV.Application.Channels
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode,
string Watermark,
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds,
int WatermarkOpacity);
int? WatermarkId);
}

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

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

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

@ -41,9 +41,10 @@ namespace ErsatzTV.Application.Channels.Commands @@ -41,9 +41,10 @@ namespace ErsatzTV.Application.Channels.Commands
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request))
ValidatePreferredLanguage(request),
await WatermarkMustExist(dbContext, request))
.Apply(
(name, number, ffmpegProfileId, preferredLanguageCode) =>
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
@ -58,18 +59,6 @@ namespace ErsatzTV.Application.Channels.Commands @@ -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())
{
Name = name,
@ -77,25 +66,10 @@ namespace ErsatzTV.Application.Channels.Commands @@ -77,25 +66,10 @@ namespace ErsatzTV.Application.Channels.Commands
FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode,
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;
});
@ -136,5 +110,22 @@ namespace ErsatzTV.Application.Channels.Commands @@ -136,5 +110,22 @@ namespace ErsatzTV.Application.Channels.Commands
.Filter(c => c > 0)
.MapT(_ => createChannel.FFmpegProfileId)
.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 @@ -14,14 +14,5 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode,
string Watermark,
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds,
int WatermarkOpacity) : IRequest<Either<BaseError, ChannelViewModel>>;
int? WatermarkId) : IRequest<Either<BaseError, ChannelViewModel>>;
}

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

@ -64,74 +64,9 @@ namespace ErsatzTV.Application.Channels.Commands @@ -64,74 +64,9 @@ namespace ErsatzTV.Application.Channels.Commands
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.WatermarkId = update.WatermarkId;
await dbContext.SaveChangesAsync();
return ProjectToViewModel(c);
}

11
ErsatzTV.Application/Channels/Mapper.cs

@ -15,16 +15,7 @@ namespace ErsatzTV.Application.Channels @@ -15,16 +15,7 @@ namespace ErsatzTV.Application.Channels
GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode,
GetWatermark(channel),
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);
channel.WatermarkId);
private static string GetLogo(Channel channel) =>
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 @@ -104,13 +104,15 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
ConfigElementKey.FFmpegPreferredLanguageCode,
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
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegWatermark);
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
}
return Unit.Default;

2
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

@ -7,6 +7,6 @@ @@ -7,6 +7,6 @@
public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { 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 @@ -26,18 +26,24 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<string> watermark =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegWatermark);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
return new FFmpegSettingsViewModel
var result = new FFmpegSettingsViewModel
{
FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty),
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
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 @@ -93,6 +93,12 @@ namespace ErsatzTV.Application.Streaming.Queries
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.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>(
await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
@ -101,7 +107,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -101,7 +107,8 @@ namespace ErsatzTV.Application.Streaming.Queries
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
now));
now,
maybeGlobalWatermark));
},
async error =>
{

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

@ -0,0 +1,9 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -3,10 +3,12 @@
public class ChannelWatermark
{
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 ChannelWatermarkSize Size { get; set; }
public ChannelWatermarkMode Mode { get; set; }
public int WidthPercent { get; set; }
public int HorizontalMarginPercent { get; set; }
public int VerticalMarginPercent { get; set; }
@ -35,4 +37,10 @@ @@ -35,4 +37,10 @@
Permanent = 1,
Intermittent = 2
}
public enum ChannelWatermarkImageSource
{
Custom = 0,
ChannelLogo = 1
}
}

2
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
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 HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");

92
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -5,7 +5,6 @@ using System.Threading.Tasks; @@ -5,7 +5,6 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
@ -15,16 +14,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -15,16 +14,13 @@ namespace ErsatzTV.Core.FFmpeg
{
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService(
IConfigElementRepository configElementRepository,
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache)
{
_configElementRepository = configElementRepository;
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache;
@ -37,7 +33,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -37,7 +33,8 @@ namespace ErsatzTV.Core.FFmpeg
MediaVersion version,
string path,
DateTimeOffset start,
DateTimeOffset now)
DateTimeOffset now,
Option<ChannelWatermark> globalWatermark)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
@ -51,40 +48,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -51,40 +48,13 @@ namespace ErsatzTV.Core.FFmpeg
start,
now);
Option<string> maybeWatermarkPath = None;
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));
}
}
(Option<ChannelWatermark> maybeWatermark, Option<string> maybeWatermarkPath) =
GetWatermarkOptions(channel, globalWatermark);
bool isAnimated = await maybeWatermarkPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false));
Option<ChannelWatermark> maybeWatermark = channel.Watermark;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
@ -187,5 +157,59 @@ namespace ErsatzTV.Core.FFmpeg @@ -187,5 +157,59 @@ namespace ErsatzTV.Core.FFmpeg
private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
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 @@ -21,11 +21,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(c => c.Artwork)
.WithOne()
.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 @@ -14,6 +14,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<ConfigElement> ConfigElements { get; set; }
public DbSet<Channel> Channels { get; set; }
public DbSet<ChannelWatermark> ChannelWatermarks { get; set; }
public DbSet<MediaSource> MediaSources { get; set; }
public DbSet<LocalMediaSource> LocalMediaSources { 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 @@ @@ -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 @@ @@ -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 @@ -213,8 +213,7 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("Number")
.IsUnique();
b.HasIndex("WatermarkId")
.IsUnique();
b.HasIndex("WatermarkId");
b.ToTable("Channel");
});
@ -234,12 +233,21 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -234,12 +233,21 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("HorizontalMarginPercent")
.HasColumnType("INTEGER");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<int>("ImageSource")
.HasColumnType("INTEGER");
b.Property<int>("Location")
.HasColumnType("INTEGER");
b.Property<int>("Mode")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Opacity")
.HasColumnType("INTEGER");
@ -1845,9 +1853,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1845,9 +1853,8 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithOne("Channel")
.HasForeignKey("ErsatzTV.Core.Domain.Channel", "WatermarkId")
.OnDelete(DeleteBehavior.Cascade);
.WithMany()
.HasForeignKey("WatermarkId");
b.Navigation("FFmpegProfile");
@ -2736,11 +2743,6 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2736,11 +2743,6 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("Channel");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Navigation("CollectionItems");

196
ErsatzTV/Pages/ChannelEditor.razor

@ -8,6 +8,8 @@ @@ -8,6 +8,8 @@
@using System.Globalization
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Channels.Queries
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries
@inject NavigationManager _navigationManager
@inject ILogger<ChannelEditor> _logger
@inject ISnackbar _snackbar
@ -15,14 +17,12 @@ @@ -15,14 +17,12 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Channel Settings</MudText>
<MudText Typo="Typo.h5">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@ -65,124 +65,14 @@ @@ -65,124 +65,14 @@
</MudButton>
</MudItem>
</MudGrid>
<div style="padding-bottom: 16px; padding-top: 20px;">
<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)"
<MudSelect Class="mt-3" Label="Watermark" @bind-Value="_model.WatermarkId" For="@(() => _model.WatermarkId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">
<MudSelectItem Value="@(ChannelWatermarkMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Permanent)">Permanent</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkMode.Intermittent)">Intermittent</MudSelectItem>
</MudSelect>
<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>
<MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</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>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -205,11 +95,13 @@ @@ -205,11 +95,13 @@
private List<FFmpegProfileViewModel> _ffmpegProfiles;
private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks;
protected override async Task OnParametersSetAsync()
{
await LoadFFmpegProfilesAsync();
await LoadFFmpegProfiles();
_availableCultures = await _mediator.Send(new GetAllLanguageCodes());
await LoadWatermarks();
if (Id.HasValue)
{
@ -224,16 +116,7 @@ @@ -224,16 +116,7 @@
_model.Logo = channelViewModel.Logo;
_model.StreamingMode = channelViewModel.StreamingMode;
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
_model.WatermarkMode = channelViewModel.WatermarkMode;
_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;
_model.WatermarkId = channelViewModel.WatermarkId;
},
() => _navigationManager.NavigateTo("404"));
}
@ -249,16 +132,6 @@ @@ -249,16 +132,6 @@
_model.Name = "New Channel";
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_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 @@ @@ -270,9 +143,12 @@
private bool IsEdit => Id.HasValue;
private async Task LoadFFmpegProfilesAsync() =>
private async Task LoadFFmpegProfiles() =>
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles());
private async Task LoadWatermarks() =>
_watermarks = await _mediator.Send(new GetAllWatermarks());
private async Task HandleSubmitAsync()
{
_messageStore.Clear();
@ -323,42 +199,4 @@ @@ -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 @@ @@ -9,7 +9,9 @@
@using ErsatzTV.Application.Configuration.Queries
@using Unit = LanguageExt.Unit
@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 ISnackbar _snackbar
@inject ILogger<Settings> _logger
@ -47,37 +49,13 @@ @@ -47,37 +49,13 @@
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement>
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
<MudItem xs="6">
<InputFile id="fileInput" OnChange="UploadWatermark" hidden/>
@if (!string.IsNullOrWhiteSpace(_ffmpegSettings.Watermark))
{
<MudElement HtmlTag="img" src="@($"artwork/watermarks/{_ffmpegSettings.Watermark}")" Style="max-height: 50px"/>
}
</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>
<MudSelect Class="mt-3" Label="Global Watermark" @bind-Value="_ffmpegSettings.GlobalWatermarkId" For="@(() => _ffmpegSettings.GlobalWatermarkId)">
<MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudForm>
</MudCardContent>
<MudCardActions>
@ -130,6 +108,7 @@ @@ -130,6 +108,7 @@
private List<FFmpegProfileViewModel> _ffmpegProfiles;
private FFmpegSettingsViewModel _ffmpegSettings;
private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks;
private int _tunerCount;
private int _libraryRefreshInterval;
@ -140,6 +119,7 @@ @@ -140,6 +119,7 @@
_ffmpegSettings = await _mediator.Send(new GetFFmpegSettings());
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await _mediator.Send(new GetAllLanguageCodes());
_watermarks = await _mediator.Send(new GetAllWatermarks());
_tunerCount = await _mediator.Send(new GetHDHRTunerCount());
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
_libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval());
@ -190,43 +170,5 @@ @@ -190,43 +170,5 @@
},
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -43,6 +43,7 @@
<MudNavMenu>
<MudNavLink Href="/channels">Channels</MudNavLink>
<MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink>
<MudNavLink Href="/watermarks">Watermarks</MudNavLink>
<MudNavGroup Title="Media Sources" Expanded="true">
<MudNavLink Href="/media/emby">Emby</MudNavLink>
<MudNavLink Href="/media/jellyfin">Jellyfin</MudNavLink>

27
ErsatzTV/Validators/ChannelEditViewModelValidator.cs

@ -27,33 +27,6 @@ namespace ErsatzTV.Validators @@ -27,33 +27,6 @@ namespace ErsatzTV.Validators
StringComparison.OrdinalIgnoreCase)))
.When(vm => !string.IsNullOrWhiteSpace(vm.PreferredLanguageCode))
.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 @@ @@ -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 @@ -12,16 +12,7 @@ namespace ErsatzTV.ViewModels
public string PreferredLanguageCode { get; set; }
public string Logo { get; set; }
public StreamingMode StreamingMode { get; set; }
public string Watermark { 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 int? WatermarkId { get; set; }
public UpdateChannel ToUpdate() =>
new(
@ -32,16 +23,7 @@ namespace ErsatzTV.ViewModels @@ -32,16 +23,7 @@ namespace ErsatzTV.ViewModels
Logo,
PreferredLanguageCode,
StreamingMode,
Watermark,
WatermarkMode,
WatermarkLocation,
WatermarkSize,
WatermarkWidth,
WatermarkHorizontalMargin,
WatermarkVerticalMargin,
WatermarkFrequencyMinutes,
WatermarkDurationSeconds,
WatermarkOpacity);
WatermarkId);
public CreateChannel ToCreate() =>
new(
@ -51,15 +33,6 @@ namespace ErsatzTV.ViewModels @@ -51,15 +33,6 @@ namespace ErsatzTV.ViewModels
Logo,
PreferredLanguageCode,
StreamingMode,
Watermark,
WatermarkMode,
WatermarkLocation,
WatermarkSize,
WatermarkWidth,
WatermarkHorizontalMargin,
WatermarkVerticalMargin,
WatermarkFrequencyMinutes,
WatermarkDurationSeconds,
WatermarkOpacity);
WatermarkId);
}
}

77
ErsatzTV/ViewModels/WatermarkEditViewModel.cs

@ -0,0 +1,77 @@ @@ -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