Compare commits

...
Author SHA1 Message Date
Jason Dove 4172074ac4 update changelog for release 45 [no docker] 2021-06-12 11:46:07 -05:00
Jason DoveandGitHub e9889cefd6 skip empty content rating (#257) 2021-06-12 11:31:32 -05:00
Jason DoveandGitHub fc59c9c284 include all content ratings in xmltv (#256) 2021-06-12 11:14:27 -05:00
Jason DoveandGitHub 0750a0712f allow animated channel watermarks (#255) 2021-06-12 06:16:52 -05:00
Jason DoveandGitHub 0365d4c8f8 add channel watermark (#254)
* wip

* wip

* implement watermark settings

* code cleanup

* update changelog
2021-06-11 21:42:06 -05:00
Jason DoveandGitHub 5b36252dd0 remove framerate normalization (#253) 2021-06-11 18:04:16 -05:00
Jason DoveandGitHub 7d852bc960 add hls hybrid mode (#252)
* fix serving channels.m3u with missing content ratings

* add hls hybrid mode
2021-06-10 20:42:58 -05:00
Jason Dove cdf10b0535 changelog for 44 again [no docker] 2021-06-09 18:41:11 -05:00
Jason Dove f0b429efb5 update changelog for release 44 [no docker] 2021-06-09 18:39:52 -05:00
Jason DoveandGitHub da5148affd quickly skip missing files during plex library scan (#251) 2021-06-07 20:34:24 -05:00
Jason DoveandGitHub cec5a09839 add us content ratings to xmltv (#250) 2021-06-07 18:58:38 -05:00
Jason DoveandGitHub e20f9be702 exclude strm files from jellyfin scanners (#249)
* exclude strm files from jellyfin scanners

* update changelog
2021-06-07 07:41:59 -05:00
Jason Dove 3bc3faa7c4 artist schedule doc update [no docker] 2021-06-06 20:25:39 -05:00
Jason DoveandGitHub db24ba84f7 add artists directly to schedules (#248) 2021-06-06 20:12:17 -05:00
Jason DoveandGitHub 8346a02747 ignore unsupported plex guids (#246) 2021-06-05 15:53:38 -05:00
Jason Dove c3b33c184f fix changelog [no docker] 2021-06-05 13:39:29 -05:00
74 changed files with 9839 additions and 193 deletions
+28 -1
View File
@@ -5,6 +5,30 @@ 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
- Include MPAA and VCHIP content ratings in XMLTV guide data
- Quickly skip missing files during Plex library scan
### Fixed
- Ignore unsupported plex guids (this prevented some libraries from scanning correctly)
- Ignore unsupported STRM files from Jellyfin
## [0.0.43-prealpha] - 2021-06-05
### Added
- Support `(Part #)` name suffixes for multi-part episode grouping
@@ -17,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Rename channel mode `TransportStream` to `MPEG-TS` and `HttpLiveStreaming` to `HLS Direct`
- Improve `HLS Direct` mode compatibility with Channels DVR Server
### Fixed
- Fix search result crashes due to missing season metadata
@@ -408,7 +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.43-prealpha...HEAD
[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
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Application.MediaItems;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetAllArtists : IRequest<List<NamedMediaItemViewModel>>;
}
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.Artists.Queries
{
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
{
private readonly IArtistRepository _artistRepository;
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public Task<List<NamedMediaItemViewModel>> Handle(
GetAllArtists request,
CancellationToken cancellationToken) =>
_artistRepository.GetAllArtists().Map(list => list.Map(ProjectToViewModel).ToList());
}
}
@@ -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);
+9 -1
View File
@@ -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 -2
View File
@@ -7,12 +7,15 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId);
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
internal static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
public static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
internal static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
new(season.Id, $"{ShowTitle(season)} ({SeasonDescription(season)})");
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???"));
private static string ShowTitle(Season season) =>
season.Show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNone("???");
@@ -79,6 +79,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[MediaItem] is required for collection type 'TelevisionSeason'");
}
break;
case ProgramScheduleItemCollectionType.Artist:
if (item.MediaItemId is null)
{
return BaseError.New("[MediaItem] is required for collection type 'Artist'");
}
break;
default:
return BaseError.New("[CollectionType] is invalid");
@@ -30,6 +30,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
duration.PlayoutDuration,
@@ -49,6 +50,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
flood.CustomTitle),
@@ -66,6 +68,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
multiple.Count,
@@ -84,6 +87,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
one.CustomTitle),
@@ -19,10 +19,12 @@ namespace ErsatzTV.Application.ProgramSchedules
public string Name => CollectionType switch
{
ProgramScheduleItemCollectionType.Collection => Collection?.Name,
ProgramScheduleItemCollectionType
.TelevisionShow => MediaItem?.Name, // $"{TelevisionShow?.Title} ({TelevisionShow?.Year})",
ProgramScheduleItemCollectionType
.TelevisionSeason => MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
ProgramScheduleItemCollectionType.TelevisionShow =>
MediaItem?.Name, // $"{TelevisionShow?.Title} ({TelevisionShow?.Year})",
ProgramScheduleItemCollectionType.TelevisionSeason =>
MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
ProgramScheduleItemCollectionType.Artist =>
MediaItem?.Name,
_ => string.Empty
};
}
@@ -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()
@@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Tests.Fakes;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Serilog;
using static LanguageExt.Prelude;
@@ -349,7 +351,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -429,7 +432,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(7);
@@ -515,7 +519,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -605,7 +610,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -699,7 +705,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
@@ -792,7 +799,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
@@ -851,7 +859,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger);
var items = new List<ProgramScheduleItem> { Flood(mediaCollection) };
+2
View File
@@ -14,6 +14,8 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
+37
View File
@@ -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
}
}
-2
View File
@@ -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
};
}
@@ -4,6 +4,7 @@
{
Collection = 0,
TelevisionShow = 1,
TelevisionSeason = 2
TelevisionSeason = 2,
Artist = 3
}
}
+2 -1
View File
@@ -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;
+23 -6
View File
@@ -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(
+18 -2
View File
@@ -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);
+2 -1
View File
@@ -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);
}
}
@@ -1,9 +1,12 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Plex
{
public interface IPlexPathReplacementService
{
Task<string> GetReplacementPlexPath(int libraryPathId, string path);
string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true);
}
}
@@ -21,5 +21,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddGenre(ArtistMetadata metadata, Genre genre);
Task<bool> AddStyle(ArtistMetadata metadata, Style style);
Task<bool> AddMood(ArtistMetadata metadata, Mood mood);
Task<List<MusicVideo>> GetArtistItems(int artistId);
Task<List<Artist>> GetAllArtists();
}
}
@@ -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);
}
}
+55 -5
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
@@ -8,6 +9,7 @@ using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Serilog;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Iptv
@@ -94,7 +96,7 @@ namespace ErsatzTV.Core.Iptv
string title = GetTitle(startItem);
string subtitle = GetSubtitle(startItem);
string description = GetDescription(startItem);
string contentRating = string.Empty;
Option<ContentRating> contentRating = GetContentRating(startItem);
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
@@ -210,12 +212,16 @@ namespace ErsatzTV.Core.Iptv
}
}
if (!string.IsNullOrWhiteSpace(contentRating))
foreach (ContentRating rating in contentRating)
{
xml.WriteStartElement("rating");
xml.WriteAttributeString("system", "MPAA");
foreach (string system in rating.System)
{
xml.WriteAttributeString("system", system);
}
xml.WriteStartElement("value");
xml.WriteString(contentRating);
xml.WriteString(rating.Value);
xml.WriteEndElement(); // value
xml.WriteEndElement(); // rating
}
@@ -322,5 +328,49 @@ namespace ErsatzTV.Core.Iptv
_ => string.Empty
};
}
private static Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
{
try
{
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata
.HeadOrNone()
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
Episode e => e.Season.Show.ShowMetadata
.HeadOrNone()
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
_ => None
};
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
return None;
}
}
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
{
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
return maybeFirst.Map(
first =>
{
string[] split = first.Split(':');
if (split.Length == 2)
{
return split[0].ToLowerInvariant() == "us"
? new ContentRating(system, split[1].ToUpperInvariant())
: new ContentRating(None, split[1].ToUpperInvariant());
}
return string.IsNullOrWhiteSpace(first)
? Option<ContentRating>.None
: new ContentRating(None, first);
}).Flatten();
}
private record ContentRating(Option<string> System, string Value);
}
}
+2 -1
View File
@@ -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);
+34 -3
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
@@ -16,10 +17,13 @@ namespace ErsatzTV.Core.Plex
{
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
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;
@@ -31,6 +35,9 @@ namespace ErsatzTV.Core.Plex
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -40,6 +47,9 @@ namespace ErsatzTV.Core.Plex
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -48,6 +58,9 @@ namespace ErsatzTV.Core.Plex
PlexServerAuthToken token,
PlexLibrary library)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,
@@ -56,9 +69,27 @@ namespace ErsatzTV.Core.Plex
await entries.Match(
async movieEntries =>
{
foreach (PlexMovie incoming in movieEntries)
var validMovies = new List<PlexMovie>();
foreach (PlexMovie movie in movieEntries.OrderBy(m => m.MovieMetadata.Head().Title))
{
decimal percentCompletion = (decimal) movieEntries.IndexOf(incoming) / movieEntries.Count;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping plex movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
}
foreach (PlexMovie incoming in validMovies)
{
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
// TODO: figure out how to rebuild playlists
@@ -92,7 +123,7 @@ namespace ErsatzTV.Core.Plex
});
}
var movieKeys = movieEntries.Map(s => s.Key).ToList();
var movieKeys = validMovies.Map(s => s.Key).ToList();
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys);
await _searchIndex.RemoveItems(ids);
@@ -31,7 +31,13 @@ namespace ErsatzTV.Core.Plex
{
List<PlexPathReplacement> replacements =
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
Option<PlexPathReplacement> maybeReplacement = replacements
return GetReplacementPlexPath(replacements, path);
}
public string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true)
{
Option<PlexPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault(
r =>
{
@@ -39,6 +45,7 @@ namespace ErsatzTV.Core.Plex
string prefix = r.PlexPath.EndsWith(separatorChar) ? r.PlexPath : r.PlexPath + separatorChar;
return path.StartsWith(prefix);
});
return maybeReplacement.Match(
replacement =>
{
@@ -52,11 +59,15 @@ namespace ErsatzTV.Core.Plex
finalPath = finalPath.Replace(@"/", @"\");
}
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
if (log)
{
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
}
return finalPath;
},
() => path);
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
@@ -18,9 +19,12 @@ namespace ErsatzTV.Core.Plex
{
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@@ -33,6 +37,9 @@ namespace ErsatzTV.Core.Plex
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -42,6 +49,9 @@ namespace ErsatzTV.Core.Plex
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -50,6 +60,9 @@ namespace ErsatzTV.Core.Plex
PlexServerAuthToken token,
PlexLibrary library)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
library,
connection,
@@ -72,7 +85,7 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
await ScanSeasons(library, result.Item, connection, token);
await ScanSeasons(library, pathReplacements, result.Item, connection, token);
if (result.IsAdded)
{
@@ -271,13 +284,14 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, Unit>> ScanSeasons(
PlexLibrary plexMediaSourceLibrary,
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
plexMediaSourceLibrary,
library,
show,
connection,
token);
@@ -291,11 +305,11 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(plexMediaSourceLibrary, incoming)
.GetOrAddPlexSeason(library, incoming)
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(plexMediaSourceLibrary, season, connection, token),
async season => await ScanEpisodes(library, pathReplacements, season, connection, token),
error =>
{
_logger.LogWarning(
@@ -315,7 +329,7 @@ namespace ErsatzTV.Core.Plex
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
library.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
@@ -355,13 +369,14 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, Unit>> ScanEpisodes(
PlexLibrary plexMediaSourceLibrary,
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
plexMediaSourceLibrary,
library,
season,
connection,
token);
@@ -369,19 +384,39 @@ namespace ErsatzTV.Core.Plex
return await entries.Match<Task<Either<BaseError, Unit>>>(
async episodeEntries =>
{
foreach (PlexEpisode incoming in episodeEntries)
var validEpisodes = new List<PlexEpisode>();
foreach (PlexEpisode episode in episodeEntries)
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping plex episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
foreach (PlexEpisode incoming in validEpisodes)
{
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexEpisode> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming)
.GetOrAddPlexEpisode(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateStatistics(
existing,
incoming,
plexMediaSourceLibrary,
library,
connection,
token))
.BindT(existing => UpdateArtwork(existing, incoming));
@@ -401,7 +436,7 @@ namespace ErsatzTV.Core.Plex
});
}
var episodeKeys = episodeEntries.Map(s => s.Key).ToList();
var episodeKeys = validEpisodes.Map(s => s.Key).ToList();
List<int> ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
@@ -412,7 +447,7 @@ namespace ErsatzTV.Core.Plex
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
library.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
@@ -18,6 +18,7 @@ namespace ErsatzTV.Core.Scheduling
public class PlayoutBuilder : IPlayoutBuilder
{
private static readonly Random Random = new();
private readonly IArtistRepository _artistRepository;
private readonly ILogger<PlayoutBuilder> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
@@ -25,10 +26,12 @@ namespace ErsatzTV.Core.Scheduling
public PlayoutBuilder(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ILogger<PlayoutBuilder> logger)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_logger = logger;
}
@@ -66,6 +69,10 @@ namespace ErsatzTV.Core.Scheduling
List<Episode> seasonItems =
await _televisionRepository.GetSeasonItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, seasonItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.Artist:
List<MusicVideo> artistItems =
await _artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, artistItems.Cast<MediaItem>().ToList());
default:
return Tuple(collectionKey, new List<MediaItem>());
}
@@ -555,6 +562,11 @@ namespace ErsatzTV.Core.Scheduling
CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId
},
ProgramScheduleItemCollectionType.Artist => new CollectionKey
{
CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
@@ -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");
}
}
@@ -146,5 +146,28 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection.ExecuteAsync(
"INSERT INTO Mood (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { mood.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<MusicVideo>> GetArtistItems(int artistId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.Include(mv => mv.MediaVersions)
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(mv => mv.ArtistId == artistId)
.ToListAsync();
}
public async Task<List<Artist>> GetAllArtists()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Artists
.AsNoTracking()
.Include(a => a.ArtistMetadata)
.ThenInclude(am => am.Artwork)
.ToListAsync();
}
}
}
@@ -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;
}
}
}
@@ -72,6 +72,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexPathReplacements
.Include(ppr => ppr.PlexMediaSource)
.Filter(r => r.PlexMediaSourceId == plexMediaSourceId)
.ToListAsync();
}
@@ -76,6 +76,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.LoadAsync();
return programSchedule.Items;
}).Sequence();
+33 -1
View File
@@ -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;
}
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -235,6 +236,12 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
var version = new MediaVersion
{
Name = "Main",
@@ -543,6 +550,12 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
var version = new MediaVersion
{
Name = "Main",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Delete_JellyfinStrmFiles : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT MI.Id FROM MediaItem MI
INNER JOIN MediaVersion MV on MV.MovieId = MI.Id
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MF.Path LIKE '%.strm')");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
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");
}
}
}
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");
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Plex.Models;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Refit;
using static LanguageExt.Prelude;
@@ -18,9 +19,15 @@ namespace ErsatzTV.Infrastructure.Plex
public class PlexServerApiClient : IPlexServerApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<PlexServerApiClient> _logger;
public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider) =>
public PlexServerApiClient(
IFallbackMetadataProvider fallbackMetadataProvider,
ILogger<PlexServerApiClient> logger)
{
_fallbackMetadataProvider = fallbackMetadataProvider;
_logger = logger;
}
public async Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
@@ -358,10 +365,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(xml.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
}
@@ -525,10 +535,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(xml.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
}
@@ -598,10 +611,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(response.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(response.PlexGuid))
{
string normalized = NormalizeGuid(response.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(response.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
@@ -711,10 +727,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(xml.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
}
@@ -763,7 +782,7 @@ namespace ErsatzTV.Infrastructure.Plex
return actor;
}
private string NormalizeGuid(string guid)
private Option<string> NormalizeGuid(string guid)
{
if (guid.StartsWith("plex://show") ||
guid.StartsWith("plex://season") ||
@@ -787,7 +806,9 @@ namespace ErsatzTV.Infrastructure.Plex
return $"tmdb://{strip2}";
}
throw new NotSupportedException(guid);
_logger.LogWarning("Unsupported guid format from Plex; ignoring: {Guid}", guid);
return None;
}
}
}
+2
View File
@@ -44,9 +44,11 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=showtitle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=strm/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvdb/@EntryIndexedValue">True</s:Boolean>
<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>
+6 -2
View File
@@ -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"),
+23
View File
@@ -5,6 +5,8 @@
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using System.Globalization
@using Unit = LanguageExt.Unit
@inject IMediator _mediator
@@ -55,6 +57,13 @@
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
</div>
</div>
</div>
@@ -187,6 +196,20 @@
}
}
private async Task AddToSchedule()
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, ArtistId, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddMusicVideoToCollection(MusicVideoCardViewModel musicVideo)
{
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
+126 -15
View File
@@ -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);
}
}
}
+8 -2
View File
@@ -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"
};
}
-3
View File
@@ -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>
+16
View File
@@ -6,6 +6,7 @@
@using ErsatzTV.Application.ProgramSchedules.Commands
@using ErsatzTV.Application.ProgramSchedules.Queries
@using ErsatzTV.Application.Television.Queries
@using ErsatzTV.Application.Artists.Queries
@inject NavigationManager _navigationManager
@inject ILogger<ScheduleItemsEditor> _logger
@inject ISnackbar _snackbar
@@ -129,6 +130,15 @@
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(s => s?.Name)"/>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Artist)
{
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Artist"
@bind-value="_selectedItem.MediaItem"
SearchFunc="@SearchArtists"
ToStringFunc="@(s => s?.Name)"/>
}
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
{
@@ -177,6 +187,7 @@
private List<MediaCollectionViewModel> _mediaCollections;
private List<NamedMediaItemViewModel> _televisionShows;
private List<NamedMediaItemViewModel> _televisionSeasons;
private List<NamedMediaItemViewModel> _artists;
private ProgramScheduleItemEditViewModel _selectedItem;
@@ -184,9 +195,11 @@
private async Task LoadScheduleItems()
{
// TODO: fix performance
_mediaCollections = await _mediator.Send(new GetAllCollections());
_televisionShows = await _mediator.Send(new GetAllTelevisionShows());
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons());
_artists = await _mediator.Send(new GetAllArtists());
string name = string.Empty;
Option<ProgramScheduleViewModel> maybeSchedule = await _mediator.Send(new GetProgramScheduleById(Id));
@@ -276,6 +289,9 @@
private Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value) =>
_televisionSeasons.Filter(s => s.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value) =>
_artists.Filter(s => s.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private async Task SaveChanges()
{
var items = _schedule.Items.Map(item => new ReplaceProgramScheduleItem(
@@ -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);
}
}
}
+26 -2
View File
@@ -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
);
}
}
@@ -54,6 +54,7 @@ namespace ErsatzTV.ViewModels
ProgramScheduleItemCollectionType.Collection => Collection?.Name,
ProgramScheduleItemCollectionType.TelevisionShow => MediaItem?.Name,
ProgramScheduleItemCollectionType.TelevisionSeason => MediaItem?.Name,
ProgramScheduleItemCollectionType.Artist => MediaItem?.Name,
_ => string.Empty
};
+1
View File
@@ -55,6 +55,7 @@ Schedule items can contain the following collection types:
- `Collection`: Select a collection that you have created manually.
- `Television Show`: Select an entire television show.
- `Television Season`: Select a specific season of a television show.
- `Artist`: Select all music videos for a specific artist.
#### Collection