add channel watermark (#254)
* wip * wip * implement watermark settings * code cleanup * update changelog
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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) =>
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
.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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
|
||||
{
|
||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
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
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
|
||||
|
||||
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
|
||||
library,
|
||||
connection,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
.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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
.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
|
||||
b.Navigation("Playouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
|
||||
{
|
||||
b.Navigation("Channel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
|
||||
{
|
||||
b.Navigation("CollectionItems");
|
||||
|
||||
@@ -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 @@
|
||||
</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 @@
|
||||
_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 @@
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
FFmpegProfileId,
|
||||
Logo,
|
||||
PreferredLanguageCode,
|
||||
StreamingMode);
|
||||
StreamingMode,
|
||||
WatermarkMode,
|
||||
WatermarkLocation,
|
||||
WatermarkSize,
|
||||
WatermarkWidth,
|
||||
WatermarkHorizontalMargin,
|
||||
WatermarkVerticalMargin,
|
||||
WatermarkFrequencyMinutes,
|
||||
WatermarkDurationSeconds);
|
||||
|
||||
public CreateChannel ToCreate() =>
|
||||
new(
|
||||
@@ -30,6 +46,14 @@ namespace ErsatzTV.ViewModels
|
||||
FFmpegProfileId,
|
||||
Logo,
|
||||
PreferredLanguageCode,
|
||||
StreamingMode);
|
||||
StreamingMode,
|
||||
WatermarkMode,
|
||||
WatermarkLocation,
|
||||
WatermarkSize,
|
||||
WatermarkWidth,
|
||||
WatermarkHorizontalMargin,
|
||||
WatermarkVerticalMargin,
|
||||
WatermarkFrequencyMinutes,
|
||||
WatermarkDurationSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user