Browse Source

add channel watermark (#254)

* wip

* wip

* implement watermark settings

* code cleanup

* update changelog
pull/255/head
Jason Dove 4 years ago committed by GitHub
parent
commit
0365d4c8f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 10
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  3. 10
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  4. 19
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  5. 10
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  6. 33
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  7. 10
      ErsatzTV.Application/Channels/Mapper.cs
  8. 2
      ErsatzTV.Core/Domain/Channel.cs
  9. 37
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  10. 71
      ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs
  11. 19
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  12. 9
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  13. 3
      ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs
  14. 8
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  15. 5
      ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs
  16. 11
      ErsatzTV.Infrastructure/Data/Configurations/ChannelWatermarkConfiguration.cs
  17. 87
      ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs
  18. 2942
      ErsatzTV.Infrastructure/Migrations/20210610122252_Add_ChannelWatermark.Designer.cs
  19. 68
      ErsatzTV.Infrastructure/Migrations/20210610122252_Add_ChannelWatermark.cs
  20. 53
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  21. 97
      ErsatzTV/Pages/ChannelEditor.razor
  22. 22
      ErsatzTV/Validators/ChannelEditViewModelValidator.cs
  23. 28
      ErsatzTV/ViewModels/ChannelEditViewModel.cs

1
CHANGELOG.md

@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
- Add experimental `HLS Hybrid` channel mode
- Media items are transcoded using the channel's ffmpeg profile and served using HLS
- Add optional channel watermark
### Changed
- Remove framerate normalization; it caused more problems than it solved

10
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -9,5 +9,13 @@ namespace ErsatzTV.Application.Channels @@ -9,5 +9,13 @@ namespace ErsatzTV.Application.Channels
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode);
StreamingMode StreamingMode,
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds);
}

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

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

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

@ -57,7 +57,7 @@ namespace ErsatzTV.Application.Channels.Commands @@ -57,7 +57,7 @@ namespace ErsatzTV.Application.Channels.Commands
});
}
return new Channel(Guid.NewGuid())
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
@ -66,6 +66,23 @@ namespace ErsatzTV.Application.Channels.Commands @@ -66,6 +66,23 @@ namespace ErsatzTV.Application.Channels.Commands
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
};
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
};
}
return channel;
});
private Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>

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

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

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

@ -61,6 +61,39 @@ namespace ErsatzTV.Application.Channels.Commands @@ -61,6 +61,39 @@ namespace ErsatzTV.Application.Channels.Commands
});
}
if (update.WatermarkMode == ChannelWatermarkMode.None)
{
await _channelRepository.RemoveWatermark(c);
}
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;
}
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
};
}
}
c.StreamingMode = update.StreamingMode;
await _channelRepository.Update(c);
return ProjectToViewModel(c);

10
ErsatzTV.Application/Channels/Mapper.cs

@ -14,7 +14,15 @@ namespace ErsatzTV.Application.Channels @@ -14,7 +14,15 @@ namespace ErsatzTV.Application.Channels
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode);
channel.StreamingMode,
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);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))

2
ErsatzTV.Core/Domain/Channel.cs

@ -14,6 +14,8 @@ namespace ErsatzTV.Core.Domain @@ -14,6 +14,8 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }

37
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
namespace ErsatzTV.Core.Domain
{
public class ChannelWatermark
{
public int Id { get; set; }
public Channel Channel { 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; }
public int FrequencyMinutes { get; set; }
public int DurationSeconds { get; set; }
}
public enum ChannelWatermarkLocation
{
BottomRight = 0,
BottomLeft = 1,
TopRight = 2,
TopLeft = 3
}
public enum ChannelWatermarkSize
{
Scaled = 0,
ActualSize = 1
}
public enum ChannelWatermarkMode
{
None = 0,
Permanent = 1,
Intermittent = 2
}
}

71
ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs

@ -17,7 +17,9 @@ namespace ErsatzTV.Core.FFmpeg @@ -17,7 +17,9 @@ namespace ErsatzTV.Core.FFmpeg
private string _inputCodec;
private bool _normalizeLoudness;
private Option<IDisplaySize> _padToSize = None;
private IDisplaySize _resolution;
private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@ -61,6 +63,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -61,6 +63,13 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
{
_watermark = watermark;
_resolution = resolution;
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
{
var complexFilter = new StringBuilder();
@ -79,6 +88,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -79,6 +88,8 @@ namespace ErsatzTV.Core.FFmpeg
var audioFilterQueue = new List<string>();
var videoFilterQueue = new List<string>();
string watermarkScale = string.Empty;
string watermarkOverlay = string.Empty;
if (_normalizeLoudness)
{
@ -128,7 +139,10 @@ namespace ErsatzTV.Core.FFmpeg @@ -128,7 +139,10 @@ namespace ErsatzTV.Core.FFmpeg
}
});
if (_scaleToSize.IsSome || _padToSize.IsSome)
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
bool usesSoftwareFilters = scaleOrPad || _watermark.IsSome;
if (usesSoftwareFilters)
{
if (acceleration != HardwareAccelerationKind.None && (isHardwareDecode || usesHardwareFilters))
{
@ -141,12 +155,42 @@ namespace ErsatzTV.Core.FFmpeg @@ -141,12 +155,42 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add(format);
}
videoFilterQueue.Add("setsar=1");
if (scaleOrPad)
{
videoFilterQueue.Add("setsar=1");
}
foreach (ChannelWatermark watermark in _watermark)
{
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
? $":enable='lt(mod(mod(time(0),60*60),{watermark.FrequencyMinutes}*60),{watermark.DurationSeconds})'"
: string.Empty;
double horizontalMargin = Math.Round(watermark.HorizontalMarginPercent / 100.0 * _resolution.Width);
double verticalMargin = Math.Round(watermark.VerticalMarginPercent / 100.0 * _resolution.Height);
string position = watermark.Location switch
{
ChannelWatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
};
if (watermark.Size == ChannelWatermarkSize.Scaled)
{
double width = Math.Round(watermark.WidthPercent / 100.0 * _resolution.Width);
watermarkScale = $"scale={width}:-1";
}
watermarkOverlay = $"overlay={position}{enable}";
}
}
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
if ((_scaleToSize.IsSome || _padToSize.IsSome) && acceleration != HardwareAccelerationKind.None)
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
string.IsNullOrWhiteSpace(watermarkOverlay))
{
string upload = acceleration switch
{
@ -173,7 +217,26 @@ namespace ErsatzTV.Core.FFmpeg @@ -173,7 +217,26 @@ namespace ErsatzTV.Core.FFmpeg
}
complexFilter.Append($"[{videoLabel}]");
complexFilter.Append(string.Join(",", videoFilterQueue));
var filters = string.Join(",", videoFilterQueue);
complexFilter.Append(filters);
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
{
complexFilter.Append("[vt]");
var watermarkLabel = "[1:v]";
if (!string.IsNullOrWhiteSpace(watermarkScale))
{
complexFilter.Append($";{watermarkLabel}{watermarkScale}[wms]");
watermarkLabel = "[wms]";
}
complexFilter.Append($";[vt]{watermarkLabel}{watermarkOverlay}");
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
{
complexFilter.Append(",hwupload");
}
}
videoLabel = "[v]";
complexFilter.Append(videoLabel);
}

19
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -152,6 +152,25 @@ namespace ErsatzTV.Core.FFmpeg @@ -152,6 +152,25 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithWatermark(
Option<ChannelWatermark> watermark,
Option<string> maybePath,
IDisplaySize resolution)
{
foreach (string path in maybePath)
{
string subfolder = path[..2];
string fullPath = Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder, path);
_arguments.Add("-i");
_arguments.Add(fullPath);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
}
return this;
}
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec)
{
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))

9
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -42,6 +42,14 @@ namespace ErsatzTV.Core.FFmpeg @@ -42,6 +42,14 @@ namespace ErsatzTV.Core.FFmpeg
start,
now);
Option<string> maybeWatermarkPath = channel.Artwork
.Filter(_ => channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => a.Path);
Option<ChannelWatermark> maybeWatermark = channel.Watermark;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
@ -50,6 +58,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -50,6 +58,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);

3
ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs

@ -12,8 +12,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -12,8 +12,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Channel>> GetByNumber(string number);
Task<List<Channel>> GetAll();
Task<List<Channel>> GetAllForGuide();
Task Update(Channel channel);
Task<bool> Update(Channel channel);
Task Delete(int channelId);
Task<int> CountPlayouts(int channelId);
Task<Unit> RemoveWatermark(Channel channel);
}
}

8
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -17,13 +17,13 @@ namespace ErsatzTV.Core.Plex @@ -17,13 +17,13 @@ namespace ErsatzTV.Core.Plex
{
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediator _mediator;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@ -60,7 +60,7 @@ namespace ErsatzTV.Core.Plex @@ -60,7 +60,7 @@ namespace ErsatzTV.Core.Plex
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,

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

@ -21,6 +21,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -21,6 +21,11 @@ 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);
}
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/ChannelWatermarkConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ChannelWatermarkConfiguration : IEntityTypeConfiguration<ChannelWatermark>
{
public void Configure(EntityTypeBuilder<ChannelWatermark> builder) => builder.ToTable("ChannelWatermark");
}
}

87
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs

@ -14,44 +14,59 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -14,44 +14,59 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class ChannelRepository : IChannelRepository
{
private readonly IDbConnection _dbConnection;
private readonly TvContext _dbContext;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public ChannelRepository(TvContext dbContext, IDbConnection dbConnection)
public ChannelRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContext = dbContext;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<Channel> Add(Channel channel)
{
await _dbContext.Channels.AddAsync(channel);
await _dbContext.SaveChangesAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.Channels.AddAsync(channel);
await dbContext.SaveChangesAsync();
return channel;
}
public Task<Option<Channel>> Get(int id) =>
_dbContext.Channels
public async Task<Option<Channel>> Get(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
}
public Task<Option<Channel>> GetByNumber(string number) =>
_dbContext.Channels
public async Task<Option<Channel>> GetByNumber(string number)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.FFmpegProfile)
.ThenInclude(p => p.Resolution)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.OrderBy(c => c.Number)
.SingleOrDefaultAsync(c => c.Number == number)
.Map(Optional);
}
public Task<List<Channel>> GetAll() =>
_dbContext.Channels
public async Task<List<Channel>> GetAll()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.ToListAsync();
}
public Task<List<Channel>> GetAllForGuide() =>
_dbContext.Channels
public async Task<List<Channel>> GetAllForGuide()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Playouts)
.ThenInclude(p => p.Items)
@ -80,23 +95,57 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -80,23 +95,57 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ToListAsync();
}
public Task Update(Channel channel)
public async Task<bool> Update(Channel channel)
{
_dbContext.Channels.Update(channel);
return _dbContext.SaveChangesAsync();
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)
{
Channel channel = await _dbContext.Channels.FindAsync(channelId);
_dbContext.Channels.Remove(channel);
await _dbContext.SaveChangesAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Channel channel = await dbContext.Channels.FindAsync(channelId);
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync();
}
public Task<int> CountPlayouts(int channelId) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM Playout WHERE ChannelId = @ChannelId",
new { ChannelId = channelId });
public async Task<Unit> 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;
}
}
}

2942
ErsatzTV.Infrastructure/Migrations/20210610122252_Add_ChannelWatermark.Designer.cs generated

File diff suppressed because it is too large Load Diff

68
ErsatzTV.Infrastructure/Migrations/20210610122252_Add_ChannelWatermark.cs

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ChannelWatermark : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "WatermarkId",
table: "Channel",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "ChannelWatermark",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Location = table.Column<int>(type: "INTEGER", nullable: false),
Size = table.Column<int>(type: "INTEGER", nullable: false),
Mode = table.Column<int>(type: "INTEGER", nullable: false),
WidthPercent = table.Column<int>(type: "INTEGER", nullable: false),
HorizontalMarginPercent = table.Column<int>(type: "INTEGER", nullable: false),
VerticalMarginPercent = table.Column<int>(type: "INTEGER", nullable: false),
FrequencyMinutes = table.Column<int>(type: "INTEGER", nullable: false),
DurationSeconds = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelWatermark", x => x.Id);
});
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);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel");
migrationBuilder.DropTable(
name: "ChannelWatermark");
migrationBuilder.DropIndex(
name: "IX_Channel_WatermarkId",
table: "Channel");
migrationBuilder.DropColumn(
name: "WatermarkId",
table: "Channel");
}
}
}

53
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -203,6 +203,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -203,6 +203,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.Property<int?>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
@ -210,9 +213,47 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -210,9 +213,47 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("Number")
.IsUnique();
b.HasIndex("WatermarkId")
.IsUnique();
b.ToTable("Channel");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("DurationSeconds")
.HasColumnType("INTEGER");
b.Property<int>("FrequencyMinutes")
.HasColumnType("INTEGER");
b.Property<int>("HorizontalMarginPercent")
.HasColumnType("INTEGER");
b.Property<int>("Location")
.HasColumnType("INTEGER");
b.Property<int>("Mode")
.HasColumnType("INTEGER");
b.Property<int>("Size")
.HasColumnType("INTEGER");
b.Property<int>("VerticalMarginPercent")
.HasColumnType("INTEGER");
b.Property<int>("WidthPercent")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("ChannelWatermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Property<int>("Id")
@ -1800,7 +1841,14 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1800,7 +1841,14 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithOne("Channel")
.HasForeignKey("ErsatzTV.Core.Domain.Channel", "WatermarkId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("FFmpegProfile");
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
@ -2685,6 +2733,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2685,6 +2733,11 @@ 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");

97
ErsatzTV/Pages/ChannelEditor.razor

@ -20,6 +20,11 @@ @@ -20,6 +20,11 @@
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Channel Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
@ -60,6 +65,82 @@ @@ -60,6 +65,82 @@
</MudButton>
</MudItem>
</MudGrid>
<div style="padding-bottom: 16px; padding-top: 20px;">
<MudText Typo="Typo.h6">Watermark Settings</MudText>
</div>
<MudSelect Class="mt-3" Label="Mode" @bind-Value="_model.WatermarkMode"
For="@(() => _model.WatermarkMode)"
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>
</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>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -101,6 +182,14 @@ @@ -101,6 +182,14 @@
_model.Logo = channelViewModel.Logo;
_model.StreamingMode = channelViewModel.StreamingMode;
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
_model.WatermarkMode = channelViewModel.WatermarkMode;
_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;
},
() => _navigationManager.NavigateTo("404"));
}
@ -116,6 +205,14 @@ @@ -116,6 +205,14 @@
_model.Name = "New Channel";
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_model.StreamingMode = StreamingMode.TransportStream;
_model.WatermarkMode = ChannelWatermarkMode.None;
_model.WatermarkLocation = ChannelWatermarkLocation.BottomRight;
_model.WatermarkSize = ChannelWatermarkSize.Scaled;
_model.WatermarkWidth = 15;
_model.WatermarkHorizontalMargin = 5;
_model.WatermarkVerticalMargin = 5;
_model.WatermarkFrequencyMinutes = 15;
_model.WatermarkDurationSeconds = 15;
}
}

22
ErsatzTV/Validators/ChannelEditViewModelValidator.cs

@ -27,6 +27,28 @@ namespace ErsatzTV.Validators @@ -27,6 +27,28 @@ 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);
}
}
}

28
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -12,6 +12,14 @@ namespace ErsatzTV.ViewModels @@ -12,6 +12,14 @@ namespace ErsatzTV.ViewModels
public string PreferredLanguageCode { get; set; }
public string Logo { get; set; }
public StreamingMode StreamingMode { 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 UpdateChannel ToUpdate() =>
new(
@ -21,7 +29,15 @@ namespace ErsatzTV.ViewModels @@ -21,7 +29,15 @@ namespace ErsatzTV.ViewModels
FFmpegProfileId,
Logo,
PreferredLanguageCode,
StreamingMode);
StreamingMode,
WatermarkMode,
WatermarkLocation,
WatermarkSize,
WatermarkWidth,
WatermarkHorizontalMargin,
WatermarkVerticalMargin,
WatermarkFrequencyMinutes,
WatermarkDurationSeconds);
public CreateChannel ToCreate() =>
new(
@ -30,6 +46,14 @@ namespace ErsatzTV.ViewModels @@ -30,6 +46,14 @@ namespace ErsatzTV.ViewModels
FFmpegProfileId,
Logo,
PreferredLanguageCode,
StreamingMode);
StreamingMode,
WatermarkMode,
WatermarkLocation,
WatermarkSize,
WatermarkWidth,
WatermarkHorizontalMargin,
WatermarkVerticalMargin,
WatermarkFrequencyMinutes,
WatermarkDurationSeconds);
}
}

Loading…
Cancel
Save