Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4172074ac4 | ||
|
|
e9889cefd6 | ||
|
|
fc59c9c284 | ||
|
|
0750a0712f | ||
|
|
0365d4c8f8 | ||
|
|
5b36252dd0 | ||
|
|
7d852bc960 | ||
|
|
cdf10b0535 |
+17
-2
@@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.45-prealpha] - 2021-06-12
|
||||
### 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
|
||||
- Include non-US (and unknown) content ratings in XMLTV
|
||||
|
||||
### Fixed
|
||||
- Fix serving channels.m3u with missing content ratings
|
||||
- Fix percent progress indicator for Jellyfin and Emby show library scans
|
||||
|
||||
## [0.0.44-prealpha] - 2021-06-09
|
||||
### Added
|
||||
- Add artists directly to schedules
|
||||
@@ -419,8 +433,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...HEAD
|
||||
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...HEAD
|
||||
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
|
||||
[0.0.44-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
|
||||
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...v0.0.43-prealpha
|
||||
[0.0.42-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
|
||||
[0.0.41-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -21,6 +21,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
NormalizeLoudness = request.NormalizeLoudness,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
FrameRate = request.FrameRate
|
||||
NormalizeAudio = request.NormalizeAudio
|
||||
});
|
||||
|
||||
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
|
||||
@@ -22,6 +22,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.FrameRate = update.FrameRate;
|
||||
await _ffmpegProfileRepository.Update(p);
|
||||
return ProjectToViewModel(p);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,5 @@ namespace ErsatzTV.Application.FFmpegProfiles
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio,
|
||||
string FrameRate);
|
||||
bool NormalizeAudio);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
|
||||
profile.NormalizeLoudness,
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeAudio,
|
||||
profile.FrameRate);
|
||||
profile.NormalizeAudio);
|
||||
|
||||
private static ResolutionViewModel Project(Resolution resolution) =>
|
||||
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
|
||||
|
||||
@@ -5,5 +5,5 @@ using MediatR;
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetHlsPlaylistByChannelNumber
|
||||
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, string>>;
|
||||
(string Scheme, string Host, string ChannelNumber, string Mode) : IRequest<Either<BaseError, string>>;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
GetHlsPlaylistByChannelNumber request,
|
||||
Channel channel)
|
||||
{
|
||||
string mode = string.IsNullOrWhiteSpace(request.Mode)
|
||||
? string.Empty
|
||||
: $"&mode={request.Mode}";
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
|
||||
return maybePlayoutItem.Match<Either<BaseError, string>>(
|
||||
@@ -48,11 +52,11 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
double timeRemaining = Math.Abs((playoutItem.FinishOffset - now).TotalSeconds);
|
||||
return $@"#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:{index}
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:{timeRemaining:F2},
|
||||
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}?index={index}&mode=hls-direct
|
||||
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}?index={index}{mode}
|
||||
";
|
||||
},
|
||||
() =>
|
||||
|
||||
@@ -467,36 +467,6 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCorrectVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_And_Framerate_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264",
|
||||
FrameRate = "24"
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "libx264" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("libx264");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
public bool NormalizeVideo { get; set; }
|
||||
public int VideoBitrate { get; set; }
|
||||
public int VideoBufferSize { get; set; }
|
||||
public string FrameRate { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public int AudioBitrate { get; set; }
|
||||
public int AudioBufferSize { get; set; }
|
||||
@@ -40,7 +39,6 @@
|
||||
AudioChannels = 2,
|
||||
AudioSampleRate = 48,
|
||||
NormalizeVideo = true,
|
||||
FrameRate = "24",
|
||||
NormalizeAudio = true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
public enum StreamingMode
|
||||
{
|
||||
TransportStream = 1,
|
||||
HttpLiveStreamingDirect = 2
|
||||
HttpLiveStreamingDirect = 2,
|
||||
HttpLiveStreamingHybrid = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +115,10 @@ namespace ErsatzTV.Core.Emby
|
||||
List<EmbyItemEtag> existingShows,
|
||||
List<EmbyShow> shows)
|
||||
{
|
||||
foreach (EmbyShow incoming in shows.OrderBy(s => s.ShowMetadata.Head().Title))
|
||||
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
|
||||
foreach (EmbyShow incoming in sortedShows)
|
||||
{
|
||||
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
|
||||
decimal percentCompletion = (decimal) sortedShows.IndexOf(incoming) / shows.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
|
||||
@@ -13,12 +13,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
private Option<TimeSpan> _audioDuration = None;
|
||||
private bool _deinterlace;
|
||||
private Option<string> _frameRate = None;
|
||||
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
|
||||
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)
|
||||
{
|
||||
@@ -62,9 +63,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithFrameRate(Option<string> frameRate)
|
||||
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
|
||||
{
|
||||
_frameRate = frameRate;
|
||||
_watermark = watermark;
|
||||
_resolution = resolution;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -86,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)
|
||||
{
|
||||
@@ -118,8 +122,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
_frameRate.IfSome(frameRate => videoFilterQueue.Add($"fps=fps={frameRate}"));
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
@@ -137,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))
|
||||
{
|
||||
@@ -150,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
|
||||
{
|
||||
@@ -182,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);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public Option<TimeSpan> AudioDuration { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public bool Deinterlace { get; set; }
|
||||
public Option<string> FrameRate { get; set; }
|
||||
public Option<int> VideoTrackTimeScale { get; set; }
|
||||
public bool NormalizeLoudness { get; set; }
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.VideoCodec = "copy";
|
||||
result.Deinterlace = false;
|
||||
break;
|
||||
case StreamingMode.HttpLiveStreamingHybrid:
|
||||
case StreamingMode.TransportStream:
|
||||
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
|
||||
|
||||
@@ -91,15 +92,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (ffmpegProfile.NormalizeVideo)
|
||||
{
|
||||
result.FrameRate = string.IsNullOrWhiteSpace(ffmpegProfile.FrameRate)
|
||||
? None
|
||||
: Some(ffmpegProfile.FrameRate);
|
||||
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
}
|
||||
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream) || result.FrameRate.IsSome)
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
|
||||
@@ -152,6 +152,29 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithWatermark(
|
||||
Option<ChannelWatermark> watermark,
|
||||
Option<string> maybePath,
|
||||
IDisplaySize resolution,
|
||||
bool isAnimated)
|
||||
{
|
||||
foreach (string path in maybePath)
|
||||
{
|
||||
if (isAnimated)
|
||||
{
|
||||
_arguments.Add("-ignore_loop");
|
||||
_arguments.Add("0");
|
||||
}
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(path);
|
||||
|
||||
_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))
|
||||
@@ -332,12 +355,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFrameRate(Option<string> frameRate)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithFrameRate(frameRate);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithVideoTrackTimeScale(Option<int> videoTrackTimeScale)
|
||||
{
|
||||
videoTrackTimeScale.IfSome(
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
@@ -11,14 +12,17 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public class FFmpegProcessService
|
||||
{
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
|
||||
public FFmpegProcessService(
|
||||
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector)
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
IImageCache imageCache)
|
||||
{
|
||||
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_imageCache = imageCache;
|
||||
}
|
||||
|
||||
public async Task<Process> ForPlayoutItem(
|
||||
@@ -42,6 +46,18 @@ 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 => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
|
||||
bool isAnimated = await maybeWatermarkPath.Match(
|
||||
p => _imageCache.IsAnimated(p),
|
||||
() => Task.FromResult(false));
|
||||
|
||||
Option<ChannelWatermark> maybeWatermark = channel.Watermark;
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
|
||||
@@ -50,7 +66,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSeek(playbackSettings.StreamSeek)
|
||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
|
||||
.WithFrameRate(playbackSettings.FrameRate)
|
||||
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
|
||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
||||
|
||||
@@ -22,7 +22,8 @@ namespace ErsatzTV.Core.Hdhr
|
||||
|
||||
public string URL => _channel.StreamingMode switch
|
||||
{
|
||||
StreamingMode.HttpLiveStreamingDirect => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
|
||||
StreamingMode.HttpLiveStreamingDirect or StreamingMode.HttpLiveStreamingHybrid =>
|
||||
$"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
|
||||
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Images
|
||||
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
|
||||
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
|
||||
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
|
||||
Task<bool> IsAnimated(string fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,11 @@ namespace ErsatzTV.Core.Iptv
|
||||
foreach (ContentRating rating in contentRating)
|
||||
{
|
||||
xml.WriteStartElement("rating");
|
||||
xml.WriteAttributeString("system", rating.System);
|
||||
foreach (string system in rating.System)
|
||||
{
|
||||
xml.WriteAttributeString("system", system);
|
||||
}
|
||||
|
||||
xml.WriteStartElement("value");
|
||||
xml.WriteString(rating.Value);
|
||||
xml.WriteEndElement(); // value
|
||||
@@ -349,20 +353,24 @@ namespace ErsatzTV.Core.Iptv
|
||||
|
||||
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
|
||||
{
|
||||
Option<string> maybeFirst = contentRating.Split('/').HeadOrNone();
|
||||
return maybeFirst.Map<Option<ContentRating>>(
|
||||
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
|
||||
return maybeFirst.Map(
|
||||
first =>
|
||||
{
|
||||
string[] split = first.Split(':');
|
||||
if (split.Length == 2 && split[0].ToLowerInvariant() == "us")
|
||||
if (split.Length == 2)
|
||||
{
|
||||
return new ContentRating(system, split[1].ToUpperInvariant());
|
||||
return split[0].ToLowerInvariant() == "us"
|
||||
? new ContentRating(system, split[1].ToUpperInvariant())
|
||||
: new ContentRating(None, split[1].ToUpperInvariant());
|
||||
}
|
||||
|
||||
return None;
|
||||
return string.IsNullOrWhiteSpace(first)
|
||||
? Option<ContentRating>.None
|
||||
: new ContentRating(None, first);
|
||||
}).Flatten();
|
||||
}
|
||||
|
||||
private record ContentRating(string System, string Value);
|
||||
private record ContentRating(Option<string> System, string Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ namespace ErsatzTV.Core.Iptv
|
||||
|
||||
string format = channel.StreamingMode switch
|
||||
{
|
||||
StreamingMode.HttpLiveStreamingDirect => "m3u8",
|
||||
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
|
||||
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
|
||||
_ => "ts"
|
||||
};
|
||||
|
||||
|
||||
@@ -115,9 +115,10 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
List<JellyfinItemEtag> existingShows,
|
||||
List<JellyfinShow> shows)
|
||||
{
|
||||
foreach (JellyfinShow incoming in shows.OrderBy(s => s.ShowMetadata.Head().Title))
|
||||
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
|
||||
foreach (JellyfinShow incoming in sortedShows)
|
||||
{
|
||||
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
|
||||
decimal percentCompletion = (decimal) sortedShows.IndexOf(incoming) / shows.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
@@ -18,10 +20,17 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
{
|
||||
private static readonly SHA1CryptoServiceProvider Crypto;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<ImageCache> _logger;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
|
||||
|
||||
public ImageCache(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
|
||||
public ImageCache(ILocalFileSystem localFileSystem, IMemoryCache memoryCache, ILogger<ImageCache> logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_memoryCache = memoryCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
|
||||
{
|
||||
@@ -120,5 +129,28 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
|
||||
return Path.Combine(baseFolder, fileName);
|
||||
}
|
||||
|
||||
public async Task<bool> IsAnimated(string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheKey = $"image.animated.{Path.GetFileName(fileName)}";
|
||||
if (_memoryCache.TryGetValue(cacheKey, out bool animated))
|
||||
{
|
||||
return animated;
|
||||
}
|
||||
|
||||
using Image image = await Image.LoadAsync(fileName);
|
||||
animated = image.Frames.Count > 1;
|
||||
_memoryCache.Set(cacheKey, animated, TimeSpan.FromDays(1));
|
||||
|
||||
return animated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unable to check image for animation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2886
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Remove_FFmpegProfileFrameRate : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FrameRate",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FrameRate",
|
||||
table: "FFmpegProfile",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -406,9 +447,6 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<int>("AudioSampleRate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FrameRate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("HardwareAcceleration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1803,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 =>
|
||||
@@ -2688,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");
|
||||
|
||||
@@ -49,5 +49,6 @@
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvshow/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=uniqueid/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Vaapi/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=VCHIP/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=xmltv/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
@@ -55,12 +55,16 @@ namespace ErsatzTV.Controllers
|
||||
error => BadRequest(error.Value)));
|
||||
|
||||
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
|
||||
public Task<IActionResult> GetHttpLiveStreamingVideo(string channelNumber) =>
|
||||
public Task<IActionResult> GetHttpLiveStreamingVideo(
|
||||
string channelNumber,
|
||||
[FromQuery]
|
||||
string mode = "mixed") =>
|
||||
_mediator.Send(
|
||||
new GetHlsPlaylistByChannelNumber(
|
||||
Request.Scheme,
|
||||
Request.Host.ToString(),
|
||||
channelNumber))
|
||||
channelNumber,
|
||||
mode))
|
||||
.Map(
|
||||
result => result.Match<IActionResult>(
|
||||
playlist => Content(playlist, "application/x-mpegurl"),
|
||||
|
||||
@@ -20,15 +20,21 @@
|
||||
<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)"/>
|
||||
<MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
|
||||
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingHybrid)">HLS Hybrid</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
|
||||
Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)">
|
||||
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">
|
||||
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
||||
{
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
@@ -59,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">
|
||||
@@ -100,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"));
|
||||
}
|
||||
@@ -115,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,20 +248,33 @@
|
||||
|
||||
private async Task UploadLogo(InputFileChangeEventArgs e)
|
||||
{
|
||||
var buffer = new byte[e.File.Size];
|
||||
await e.File.OpenReadStream().ReadAsync(buffer);
|
||||
Either<BaseError, string> maybeCacheFileName = await _mediator.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Logo));
|
||||
maybeCacheFileName.Match(
|
||||
relativeFileName =>
|
||||
{
|
||||
_model.Logo = relativeFileName;
|
||||
StateHasChanged();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
||||
_logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
||||
});
|
||||
try
|
||||
{
|
||||
var buffer = new byte[e.File.Size];
|
||||
await e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024).ReadAsync(buffer);
|
||||
Either<BaseError, string> maybeCacheFileName = await _mediator.Send(new SaveArtworkToDisk(buffer, ArtworkKind.Logo));
|
||||
maybeCacheFileName.Match(
|
||||
relativeFileName =>
|
||||
{
|
||||
_model.Logo = relativeFileName;
|
||||
StateHasChanged();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
||||
_logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
_snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
|
||||
_logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error);
|
||||
_logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -50,9 +50,9 @@
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Language">@context.PreferredLanguageCode</MudTd>
|
||||
<MudTd DataLabel="Mode">@(context.StreamingMode == StreamingMode.TransportStream ? "MPEG-TS" : "HLS Direct")</MudTd>
|
||||
<MudTd DataLabel="Mode">@GetStreamingMode(context.StreamingMode)</MudTd>
|
||||
<MudTd DataLabel="FFmpeg Profile">
|
||||
@if (context.StreamingMode == StreamingMode.TransportStream)
|
||||
@if (context.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
{
|
||||
@_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId)?.Name
|
||||
}
|
||||
@@ -138,4 +138,10 @@
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch {
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingHybrid => "HLS Hybrid",
|
||||
_ => "MPEG-TS"
|
||||
};
|
||||
|
||||
}
|
||||
@@ -58,9 +58,6 @@
|
||||
}
|
||||
</MudSelect>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Frame Rate" @bind-Value="_model.FrameRate" For="@(() => _model.FrameRate)" Adornment="Adornment.End" AdornmentText="fps"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video" @bind-Checked="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)"/>
|
||||
</MudElement>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ namespace ErsatzTV.ViewModels
|
||||
VideoBitrate = viewModel.VideoBitrate;
|
||||
VideoBufferSize = viewModel.VideoBufferSize;
|
||||
VideoCodec = viewModel.VideoCodec;
|
||||
FrameRate = viewModel.FrameRate;
|
||||
}
|
||||
|
||||
public int AudioBitrate { get; set; }
|
||||
@@ -50,7 +49,6 @@ namespace ErsatzTV.ViewModels
|
||||
public int VideoBitrate { get; set; }
|
||||
public int VideoBufferSize { get; set; }
|
||||
public string VideoCodec { get; set; }
|
||||
public string FrameRate { get; set; }
|
||||
|
||||
public CreateFFmpegProfile ToCreate() =>
|
||||
new(
|
||||
@@ -69,8 +67,7 @@ namespace ErsatzTV.ViewModels
|
||||
NormalizeLoudness,
|
||||
AudioChannels,
|
||||
AudioSampleRate,
|
||||
NormalizeAudio,
|
||||
FrameRate
|
||||
NormalizeAudio
|
||||
);
|
||||
|
||||
public UpdateFFmpegProfile ToUpdate() =>
|
||||
@@ -91,8 +88,7 @@ namespace ErsatzTV.ViewModels
|
||||
NormalizeLoudness,
|
||||
AudioChannels,
|
||||
AudioSampleRate,
|
||||
NormalizeAudio,
|
||||
FrameRate
|
||||
NormalizeAudio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user