Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4172074ac4 | ||
|
|
e9889cefd6 | ||
|
|
fc59c9c284 | ||
|
|
0750a0712f | ||
|
|
0365d4c8f8 | ||
|
|
5b36252dd0 | ||
|
|
7d852bc960 | ||
|
|
cdf10b0535 | ||
|
|
f0b429efb5 | ||
|
|
da5148affd | ||
|
|
cec5a09839 | ||
|
|
e20f9be702 | ||
|
|
3bc3faa7c4 | ||
|
|
db24ba84f7 | ||
|
|
8346a02747 | ||
|
|
c3b33c184f |
+28
-1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) };
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
Collection = 0,
|
||||
TelevisionShow = 1,
|
||||
TelevisionSeason = 2
|
||||
TelevisionSeason = 2,
|
||||
Artist = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2889
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user