From a0740de972c178a23bd793459e2d89f715186cd5 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sun, 13 Jun 2021 21:45:52 -0500 Subject: [PATCH] add global and channel watermark overrides (#260) * add global watermark setting * add channel watermark override * update changelog --- CHANGELOG.md | 2 + .../Channels/ChannelViewModel.cs | 1 + .../Channels/Commands/CreateChannel.cs | 1 + .../Channels/Commands/CreateChannelHandler.cs | 12 +++ .../Channels/Commands/UpdateChannel.cs | 1 + .../Channels/Commands/UpdateChannelHandler.cs | 91 ++++++++++++++----- ErsatzTV.Application/Channels/Mapper.cs | 5 + .../Commands/UpdateFFmpegSettingsHandler.cs | 9 ++ .../FFmpegProfiles/FFmpegSettingsViewModel.cs | 1 + .../Queries/GetFFmpegSettingsHandler.cs | 5 +- ErsatzTV.Core/Domain/ConfigElementKey.cs | 1 + ErsatzTV.Core/Domain/Metadata/ArtworkKind.cs | 3 +- ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 37 +++++++- ErsatzTV.Core/FileSystemLayout.cs | 1 + .../Repositories/IChannelRepository.cs | 2 - .../Repositories/IConfigElementRepository.cs | 1 + .../Data/Repositories/ChannelRepository.cs | 46 +--------- .../Repositories/ConfigElementRepository.cs | 15 +++ ErsatzTV.Infrastructure/Images/ImageCache.cs | 2 + ErsatzTV/Controllers/ArtworkController.cs | 10 ++ ErsatzTV/Pages/ChannelEditor.razor | 71 +++++++++++++++ ErsatzTV/Pages/Settings.razor | 70 ++++++++++++++ ErsatzTV/ViewModels/ChannelEditViewModel.cs | 3 + 23 files changed, 313 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2eb6ce5..16f94baf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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 ### Changed - Remove unused API and SDK project; may reintroduce in the future but for now they have fallen out of date diff --git a/ErsatzTV.Application/Channels/ChannelViewModel.cs b/ErsatzTV.Application/Channels/ChannelViewModel.cs index 4aa71891b..bd8aa415c 100644 --- a/ErsatzTV.Application/Channels/ChannelViewModel.cs +++ b/ErsatzTV.Application/Channels/ChannelViewModel.cs @@ -10,6 +10,7 @@ namespace ErsatzTV.Application.Channels string Logo, string PreferredLanguageCode, StreamingMode StreamingMode, + string Watermark, ChannelWatermarkMode WatermarkMode, ChannelWatermarkLocation WatermarkLocation, ChannelWatermarkSize WatermarkSize, diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs index d972f233c..f2745055a 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs @@ -13,6 +13,7 @@ namespace ErsatzTV.Application.Channels.Commands string Logo, string PreferredLanguageCode, StreamingMode StreamingMode, + string Watermark, ChannelWatermarkMode WatermarkMode, ChannelWatermarkLocation WatermarkLocation, ChannelWatermarkSize WatermarkSize, diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs index 981a74fec..5372af675 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs @@ -58,6 +58,18 @@ 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, diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs index a0c30142e..2e9706b38 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs @@ -14,6 +14,7 @@ namespace ErsatzTV.Application.Channels.Commands string Logo, string PreferredLanguageCode, StreamingMode StreamingMode, + string Watermark, ChannelWatermarkMode WatermarkMode, ChannelWatermarkLocation WatermarkLocation, ChannelWatermarkSize WatermarkSize, diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index 561f40644..de3baf4e1 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -7,9 +7,11 @@ using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; using LanguageExt; using MediatR; +using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.Channels.Mapper; using static LanguageExt.Prelude; @@ -17,28 +19,30 @@ namespace ErsatzTV.Application.Channels.Commands { public class UpdateChannelHandler : IRequestHandler> { - private readonly IChannelRepository _channelRepository; + private readonly IDbContextFactory _dbContextFactory; - public UpdateChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository; + public UpdateChannelHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; - public Task> Handle( + public async Task> Handle( UpdateChannel request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(c => ApplyUpdateRequest(c, request)) - .Bind(v => v.ToEitherAsync()); + CancellationToken cancellationToken) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request)); + } - private async Task ApplyUpdateRequest(Channel c, UpdateChannel update) + private async Task ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update) { c.Name = update.Name; c.Number = update.Number; c.FFmpegProfileId = update.FFmpegProfileId; c.PreferredLanguageCode = update.PreferredLanguageCode; + c.Artwork ??= new List(); if (!string.IsNullOrWhiteSpace(update.Logo)) { - c.Artwork ??= new List(); - Option maybeLogo = Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo); @@ -61,9 +65,40 @@ namespace ErsatzTV.Application.Channels.Commands }); } + if (!string.IsNullOrWhiteSpace(update.Watermark)) + { + Option 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) { - await _channelRepository.RemoveWatermark(c); + if (c.Watermark != null) + { + dbContext.Remove(c.Watermark); + } } else { @@ -97,27 +132,37 @@ namespace ErsatzTV.Application.Channels.Commands } c.StreamingMode = update.StreamingMode; - await _channelRepository.Update(c); + await dbContext.SaveChangesAsync(); return ProjectToViewModel(c); } - private async Task> Validate(UpdateChannel request) => - (await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request), + private async Task> Validate(TvContext dbContext, UpdateChannel request) => + (await ChannelMustExist(dbContext, request), ValidateName(request), + await ValidateNumber(dbContext, request), ValidatePreferredLanguage(request)) .Apply((channelToUpdate, _, _, _) => channelToUpdate); - private Task> ChannelMustExist(UpdateChannel updateChannel) => - _channelRepository.Get(updateChannel.ChannelId) - .Map(v => v.ToValidation("Channel does not exist.")); + private static Task> ChannelMustExist( + TvContext dbContext, + UpdateChannel updateChannel) => + dbContext.Channels + .Include(c => c.Artwork) + .Include(c => c.Watermark) + .SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId) + .Map(o => o.ToValidation("Channel does not exist.")); - private Validation ValidateName(UpdateChannel updateChannel) => + private static Validation ValidateName(UpdateChannel updateChannel) => updateChannel.NotEmpty(c => c.Name) .Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name)); - private async Task> ValidateNumber(UpdateChannel updateChannel) + private static async Task> ValidateNumber( + TvContext dbContext, + UpdateChannel updateChannel) { - Option match = await _channelRepository.GetByNumber(updateChannel.Number); - int matchId = await match.Map(c => c.Id).IfNoneAsync(updateChannel.ChannelId); + int matchId = await dbContext.Channels + .SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number) + .Match(c => c.Id, () => updateChannel.ChannelId); + if (matchId == updateChannel.ChannelId) { if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator)) @@ -131,7 +176,7 @@ namespace ErsatzTV.Application.Channels.Commands return BaseError.New("Channel number must be unique"); } - private Validation ValidatePreferredLanguage(UpdateChannel updateChannel) => + private static Validation ValidatePreferredLanguage(UpdateChannel updateChannel) => Optional(updateChannel.PreferredLanguageCode ?? string.Empty) .Filter( lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any( diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index d9a7f1ccb..22344d44f 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -15,6 +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, @@ -28,5 +29,9 @@ namespace ErsatzTV.Application.Channels private static string GetLogo(Channel channel) => Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) .Match(a => a.Path, string.Empty); + + private static string GetWatermark(Channel channel) => + Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Watermark)) + .Match(a => a.Path, string.Empty); } } diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs index 95fdc53da..296915901 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs @@ -104,6 +104,15 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands ConfigElementKey.FFmpegPreferredLanguageCode, request.Settings.PreferredLanguageCode); + if (!string.IsNullOrWhiteSpace(request.Settings.Watermark)) + { + await _configElementRepository.Upsert(ConfigElementKey.FFmpegWatermark, request.Settings.Watermark); + } + else + { + await _configElementRepository.Delete(ConfigElementKey.FFmpegWatermark); + } + return Unit.Default; } } diff --git a/ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs b/ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs index 1e4f7ed17..bc10ac9c5 100644 --- a/ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs +++ b/ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs @@ -7,5 +7,6 @@ public int DefaultFFmpegProfileId { get; set; } public string PreferredLanguageCode { get; set; } public bool SaveReports { get; set; } + public string Watermark { get; set; } } } diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs index b3a502b29..a21974e14 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs @@ -26,6 +26,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries await _configElementRepository.GetValue(ConfigElementKey.FFmpegSaveReports); Option preferredLanguageCode = await _configElementRepository.GetValue(ConfigElementKey.FFmpegPreferredLanguageCode); + Option watermark = + await _configElementRepository.GetValue(ConfigElementKey.FFmpegWatermark); return new FFmpegSettingsViewModel { @@ -33,7 +35,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries FFprobePath = await ffprobePath.IfNoneAsync(string.Empty), DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0), SaveReports = await saveReports.IfNoneAsync(false), - PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng") + PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"), + Watermark = await watermark.IfNoneAsync(string.Empty) }; } } diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index 8cbf96c04..87c5f7d8e 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -12,6 +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 SearchIndexVersion => new("search_index.version"); public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count"); public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size"); diff --git a/ErsatzTV.Core/Domain/Metadata/ArtworkKind.cs b/ErsatzTV.Core/Domain/Metadata/ArtworkKind.cs index edd4c3e7a..47d89c1b6 100644 --- a/ErsatzTV.Core/Domain/Metadata/ArtworkKind.cs +++ b/ErsatzTV.Core/Domain/Metadata/ArtworkKind.cs @@ -5,6 +5,7 @@ Poster = 0, Thumbnail = 1, Logo = 2, - FanArt = 3 + FanArt = 3, + Watermark = 4 } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 57dac8a5d..26f9810b0 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -5,7 +5,9 @@ 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; namespace ErsatzTV.Core.FFmpeg { @@ -13,13 +15,16 @@ 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; @@ -46,11 +51,33 @@ namespace ErsatzTV.Core.FFmpeg start, now); - Option maybeWatermarkPath = channel.Artwork - .Filter(_ => channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect) - .Filter(a => a.ArtworkKind == ArtworkKind.Logo) - .HeadOrNone() - .Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option.None)); + Option 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.None)); + + // check for global watermark + if (maybeWatermarkPath.IsNone) + { + maybeWatermarkPath = await _configElementRepository + .GetValue(ConfigElementKey.FFmpegWatermark) + .MapT(a => _imageCache.GetPathForImage(a, ArtworkKind.Watermark, Option.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.None)); + } + } bool isAnimated = await maybeWatermarkPath.Match( p => _imageCache.IsAnimated(p), diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 1072234a4..97b7dc685 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -31,5 +31,6 @@ namespace ErsatzTV.Core public static readonly string ThumbnailCacheFolder = Path.Combine(ArtworkCacheFolder, "thumbnails"); public static readonly string LogoCacheFolder = Path.Combine(ArtworkCacheFolder, "logos"); public static readonly string FanArtCacheFolder = Path.Combine(ArtworkCacheFolder, "fanart"); + public static readonly string WatermarkCacheFolder = Path.Combine(ArtworkCacheFolder, "watermarks"); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs index d7338d4a8..9895b218d 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs @@ -11,8 +11,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> GetByNumber(string number); Task> GetAll(); Task> GetAllForGuide(); - Task Update(Channel channel); Task Delete(int channelId); - Task RemoveWatermark(Channel channel); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs index 2a1a4faf3..fdc33982b 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IConfigElementRepository.cs @@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> Get(ConfigElementKey key); Task> GetValue(ConfigElementKey key); Task Delete(ConfigElement configElement); + Task Delete(ConfigElementKey configElementKey); } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index bc7814ea2..90848a9d5 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -1,8 +1,6 @@ using System.Collections.Generic; -using System.Data; using System.Linq; using System.Threading.Tasks; -using Dapper; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; @@ -13,14 +11,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { public class ChannelRepository : IChannelRepository { - private readonly IDbConnection _dbConnection; private readonly IDbContextFactory _dbContextFactory; - public ChannelRepository(IDbContextFactory dbContextFactory, IDbConnection dbConnection) - { + public ChannelRepository(IDbContextFactory dbContextFactory) => _dbContextFactory = dbContextFactory; - _dbConnection = dbConnection; - } public async Task> Get(int id) { @@ -89,25 +83,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ToListAsync(); } - public async Task Update(Channel channel) - { - await using TvContext dbContext = _dbContextFactory.CreateDbContext(); - dbContext.Entry(channel).State = EntityState.Modified; - if (channel.Watermark != null) - { - dbContext.Entry(channel.Watermark).State = - channel.WatermarkId == null ? EntityState.Added : EntityState.Modified; - } - - foreach (Artwork artwork in Optional(channel.Artwork).Flatten()) - { - dbContext.Entry(artwork).State = artwork.Id > 0 ? EntityState.Modified : EntityState.Added; - } - - bool result = await dbContext.SaveChangesAsync() > 0; - return result; - } - public async Task Delete(int channelId) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); @@ -115,24 +90,5 @@ namespace ErsatzTV.Infrastructure.Data.Repositories dbContext.Channels.Remove(channel); await dbContext.SaveChangesAsync(); } - - public async Task RemoveWatermark(Channel channel) - { - if (channel.Watermark != null) - { - await _dbConnection.ExecuteAsync( - "UPDATE Channel SET WatermarkId = NULL WHERE Id = @ChannelId", - new { ChannelId = channel.Id }); - - await _dbConnection.ExecuteAsync( - "DELETE FROM ChannelWatermark WHERE Id = @WatermarkId", - new { channel.WatermarkId }); - - channel.Watermark = null; - channel.WatermarkId = null; - } - - return Unit.Default; - } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs index 0d5b08525..6b735c9cf 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ConfigElementRepository.cs @@ -63,5 +63,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories dbContext.ConfigElements.Remove(configElement); await dbContext.SaveChangesAsync(); } + + public async Task Delete(ConfigElementKey configElementKey) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await dbContext.ConfigElements + .SelectOneAsync(ce => ce.Key, ce => ce.Key == configElementKey.Key); + foreach (ConfigElement element in maybeExisting) + { + dbContext.ConfigElements.Remove(element); + } + + await dbContext.SaveChangesAsync(); + + return Unit.Default; + } } } diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs index be9541c7a..79fa51082 100644 --- a/ErsatzTV.Infrastructure/Images/ImageCache.cs +++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs @@ -66,6 +66,7 @@ namespace ErsatzTV.Infrastructure.Images ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder), ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder), ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder), + ArtworkKind.Watermark => Path.Combine(FileSystemLayout.WatermarkCacheFolder, subfolder), _ => FileSystemLayout.LegacyImageCacheFolder }; string target = Path.Combine(baseFolder, hex); @@ -124,6 +125,7 @@ namespace ErsatzTV.Infrastructure.Images ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder), ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder), ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder), + ArtworkKind.Watermark => Path.Combine(FileSystemLayout.WatermarkCacheFolder, subfolder), _ => FileSystemLayout.LegacyImageCacheFolder }; diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index aae9991c4..c3d0621f0 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -46,6 +46,16 @@ namespace ErsatzTV.Controllers Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } + [HttpGet("/artwork/watermarks/{fileName}")] + public async Task GetWatermark(string fileName) + { + Either cachedImagePath = + await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Watermark)); + return cachedImagePath.Match( + Left: _ => new NotFoundResult(), + Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); + } + [HttpGet("/artwork/fanart/{fileName}")] public async Task GetFanArt(string fileName) { diff --git a/ErsatzTV/Pages/ChannelEditor.razor b/ErsatzTV/Pages/ChannelEditor.razor index 5e47604af..9b38c0c8f 100644 --- a/ErsatzTV/Pages/ChannelEditor.razor +++ b/ErsatzTV/Pages/ChannelEditor.razor @@ -68,6 +68,37 @@
Watermark Settings
+ + + + + @if (string.IsNullOrWhiteSpace(_model.Watermark)) + { + + Upload Watermark + + } + else + { + + Delete Watermark + + } + + @@ -194,6 +225,7 @@ _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; @@ -218,6 +250,7 @@ _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; @@ -289,5 +322,43 @@ _logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message); } } + + 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 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(); + } } \ No newline at end of file diff --git a/ErsatzTV/Pages/Settings.razor b/ErsatzTV/Pages/Settings.razor index 35bd9d3af..586de67ad 100644 --- a/ErsatzTV/Pages/Settings.razor +++ b/ErsatzTV/Pages/Settings.razor @@ -9,6 +9,7 @@ @using ErsatzTV.Application.Configuration.Queries @using Unit = LanguageExt.Unit @using ErsatzTV.Application.Configuration.Commands +@using ErsatzTV.Application.Images.Commands @inject IMediator _mediator @inject ISnackbar _snackbar @inject ILogger _logger @@ -46,6 +47,37 @@ Color="Color.Primary" @bind-Checked="@_ffmpegSettings.SaveReports"/> + + + + + @if (string.IsNullOrWhiteSpace(_ffmpegSettings.Watermark)) + { + + Upload Watermark + + } + else + { + + Delete Watermark + + } + + @@ -158,5 +190,43 @@ }, 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 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(); + } } \ No newline at end of file diff --git a/ErsatzTV/ViewModels/ChannelEditViewModel.cs b/ErsatzTV/ViewModels/ChannelEditViewModel.cs index 14316fb70..e77fa035f 100644 --- a/ErsatzTV/ViewModels/ChannelEditViewModel.cs +++ b/ErsatzTV/ViewModels/ChannelEditViewModel.cs @@ -12,6 +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; } @@ -31,6 +32,7 @@ namespace ErsatzTV.ViewModels Logo, PreferredLanguageCode, StreamingMode, + Watermark, WatermarkMode, WatermarkLocation, WatermarkSize, @@ -49,6 +51,7 @@ namespace ErsatzTV.ViewModels Logo, PreferredLanguageCode, StreamingMode, + Watermark, WatermarkMode, WatermarkLocation, WatermarkSize,