Compare commits

..
Author SHA1 Message Date
Jason Dove 6bec9c5f07 update docs for 0.0.43-prealpha [no docker] 2021-06-05 13:36:08 -05:00
Jason DoveandGitHub 0ef03d66f3 improve hls direct compatibility with channels dvr (#245)
* rename HttpLiveStreaming to HttpLiveStreamingDirect

* improve hls direct compatibility with channels dvr

* code cleanup
2021-06-05 13:15:39 -05:00
Jason DoveandGitHub 10c422a3eb save channels table page size (#244)
* save channel table page size

* update changelog
2021-06-05 10:15:34 -05:00
Jason DoveandGitHub 6c867d0d51 support multi-episode files from plex (#243)
* minor fallback metadata bug fixes

* support multi-episode files from plex
2021-06-04 15:06:19 -05:00
Jason DoveandGitHub ed0796ad58 force scan local season folders to pick up multi-episode files (#242) 2021-06-04 11:42:03 -05:00
Jason DoveandGitHub 49109ac121 fix missing season metadata (#241) 2021-06-04 10:37:56 -05:00
Jason DoveandGitHub 3e3bbcf38e support multi-episode files in local libraries (#240)
* add unused episode nfo reader

* move episode number from episode to episode metadata

* first pass at loading multi-episode metadata from nfo files

* fix episode scanning

* local multi-part episode fixes

* code cleanup
2021-06-04 06:00:35 -05:00
Jason DoveandGitHub ce9ef72799 support (part #) names for multi-episode grouping (#238) 2021-06-01 07:15:17 -05:00
Jason Dove f8631a1f12 release 0.0.42-prealpha 2021-05-31 13:08:55 -05:00
Jason DoveandGitHub c70f153241 keep crossover episodes together (#237) 2021-05-31 11:58:56 -05:00
Jason DoveandGitHub eee10dee22 skip zero duration items when building playouts (#236) 2021-05-31 07:31:15 -05:00
Jason DoveandGitHub 9f575dbd94 fix stuck playout builds (#235)
* fix stuck playout builds

* code cleanup
2021-05-31 06:38:54 -05:00
80 changed files with 16110 additions and 372 deletions
+32 -2
View File
@@ -3,7 +3,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
## [Unreleased]
## [0.0.43-prealpha] - 2021-06-05
### Added
- Support `(Part #)` name suffixes for multi-part episode grouping
- Support multi-episode files in local and Plex libraries
- Save Channels table page size
- Add optional query string parameter to M3U channel playlist to allow some customization per client
- `?mode=ts` will force `MPEG-TS` mode for all channels
- `?mode=hls-direct` will force `HLS Direct` mode for all channels
- `?mode=mixed` or no parameter will maintain existing behavior
### Changed
- Rename channel mode `TransportStream` to `MPEG-TS` and `HttpLiveStreaming` to `HLS Direct`
### Fixed
- Fix search result crashes due to missing season metadata
## [0.0.42-prealpha] - 2021-05-31
### Added
- Support roman numerals and english integer names for multi-part episode grouping
- Add option to treat entire collection as a single show with multi-part episode grouping
- This is useful for multi-part episodes that span multiple shows (crossovers)
### Changed
- Skip zero duration items when building a playout, rather than aborting the playout build
### Fixed
- Fix edge case where a playout rebuild would get stuck and block all other playouts and local library scans
## [0.0.41-prealpha] - 2021-05-30
### Added
@@ -380,7 +408,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.41-prealpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...HEAD
[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.40-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.39-prealpha...v0.0.40-prealpha
[0.0.39-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.38-prealpha...v0.0.39-prealpha
@@ -3,5 +3,5 @@ using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public record GetChannelPlaylist(string Scheme, string Host) : IRequest<ChannelPlaylist>;
public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using LanguageExt;
@@ -16,6 +18,31 @@ namespace ErsatzTV.Application.Channels.Queries
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
var result = new List<Channel>();
foreach (Channel channel in channels)
{
switch (mode.ToLowerInvariant())
{
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
break;
case "ts":
channel.StreamingMode = StreamingMode.TransportStream;
result.Add(channel);
break;
default:
result.Add(channel);
break;
}
}
return result;
}
}
}
+20 -5
View File
@@ -50,17 +50,14 @@ namespace ErsatzTV.Application.MediaCards
episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.Season.SeasonNumber,
episodeMetadata.Episode.EpisodeNumber,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, () => 0),
episodeMetadata.Title,
episodeMetadata.SortTitle,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
isSearchResult
? GetPoster(
episodeMetadata.Episode.Season.SeasonMetadata.Head(),
maybeJellyfin,
maybeEmby)
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
@@ -146,6 +143,24 @@ namespace ErsatzTV.Application.MediaCards
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";
private static string GetEpisodePoster(
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
Option<SeasonMetadata> maybeSeasonMetadata = episodeMetadata.Episode.Season.SeasonMetadata.HeadOrNone();
return maybeSeasonMetadata.Match(
seasonMetadata => GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
() =>
{
Option<ShowMetadata> maybeShowMetadata =
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone();
return maybeShowMetadata.Match(
showMetadata => GetPoster(showMetadata, maybeJellyfin, maybeEmby),
() => string.Empty);
});
}
private static string GetPoster(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
+11 -3
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts
@@ -31,9 +32,16 @@ namespace ErsatzTV.Application.Playouts
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList();
var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList();
if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
{
return "[unknown episode]";
}
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
var titlesString = $"{string.Join('/', episodeTitles)}";
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
public record CreateProgramSchedule(
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
}
@@ -34,13 +34,18 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
private Task<Validation<BaseError, ProgramSchedule>> Validate(CreateProgramSchedule request) =>
ValidateName(request)
.MapT(
name => new ProgramSchedule
name =>
{
Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether =
bool keepMultiPartEpisodesTogether =
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether
request.KeepMultiPartEpisodesTogether;
return new ProgramSchedule
{
Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows
};
});
private async Task<Validation<BaseError, string>> ValidateName(CreateProgramSchedule createProgramSchedule)
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int ProgramScheduleId,
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
}
@@ -40,13 +40,16 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified
bool needToRebuildPlayout =
programSchedule.MediaCollectionPlaybackOrder != update.MediaCollectionPlaybackOrder ||
programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether;
programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != update.TreatCollectionsAsShows;
programSchedule.Name = update.Name;
programSchedule.MediaCollectionPlaybackOrder = update.MediaCollectionPlaybackOrder;
programSchedule.KeepMultiPartEpisodesTogether =
update.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
update.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
update.TreatCollectionsAsShows;
await _programScheduleRepository.Update(programSchedule);
if (needToRebuildPlayout)
@@ -10,7 +10,8 @@ namespace ErsatzTV.Application.ProgramSchedules
programSchedule.Id,
programSchedule.Name,
programSchedule.MediaCollectionPlaybackOrder,
programSchedule.KeepMultiPartEpisodesTogether);
programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch
@@ -6,5 +6,6 @@ namespace ErsatzTV.Application.ProgramSchedules
int Id,
string Name,
PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether);
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows);
}
@@ -39,6 +39,18 @@ namespace ErsatzTV.Application.Streaming.Queries
private async Task<Validation<BaseError, Channel>> ChannelMustExist(T request) =>
(await _channelRepository.GetByNumber(request.ChannelNumber))
.Map(
channel =>
{
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
return channel;
})
.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.");
private Task<Validation<BaseError, string>> FFmpegPathMustExist() =>
@@ -5,5 +5,5 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record FFmpegProcessRequest(string ChannelNumber) : IRequest<Either<BaseError, Process>>;
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
}
@@ -2,7 +2,9 @@
{
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(channelNumber)
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts")
{
Scheme = scheme;
Host = host;
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Serilog;
namespace ErsatzTV.Application.Streaming.Queries
@@ -14,14 +15,17 @@ namespace ErsatzTV.Application.Streaming.Queries
GetHlsPlaylistByChannelNumberHandler : IRequestHandler<GetHlsPlaylistByChannelNumber, Either<BaseError, string>>
{
private readonly IChannelRepository _channelRepository;
private readonly IMemoryCache _memoryCache;
private readonly IPlayoutRepository _playoutRepository;
public GetHlsPlaylistByChannelNumberHandler(
IChannelRepository channelRepository,
IPlayoutRepository playoutRepository)
IPlayoutRepository playoutRepository,
IMemoryCache memoryCache)
{
_channelRepository = channelRepository;
_playoutRepository = playoutRepository;
_memoryCache = memoryCache;
}
public Task<Either<BaseError, string>> Handle(
@@ -40,12 +44,15 @@ namespace ErsatzTV.Application.Streaming.Queries
return maybePlayoutItem.Match<Either<BaseError, string>>(
playoutItem =>
{
double timeRemaining = Math.Abs((playoutItem.Finish - now).TotalSeconds);
long index = GetIndexForChannel(channel, playoutItem);
double timeRemaining = Math.Abs((playoutItem.FinishOffset - now).TotalSeconds);
return $@"#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:18000
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:{index}
#EXT-X-DISCONTINUITY
#EXTINF:{timeRemaining:F2},
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}?index={index}&mode=hls-direct
";
},
() =>
@@ -59,5 +66,36 @@ namespace ErsatzTV.Application.Streaming.Queries
private async Task<Validation<BaseError, Channel>> ChannelMustExist(GetHlsPlaylistByChannelNumber request) =>
(await _channelRepository.GetByNumber(request.ChannelNumber))
.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.");
private long GetIndexForChannel(Channel channel, PlayoutItem playoutItem)
{
long ticks = playoutItem.Start.Ticks;
var key = new ChannelIndexKey(channel.Id);
long index;
if (_memoryCache.TryGetValue(key, out ChannelIndexRecord channelRecord))
{
if (channelRecord.StartTicks == ticks)
{
index = channelRecord.Index;
}
else
{
index = channelRecord.Index + 1;
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
}
}
else
{
index = 1;
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
}
return index;
}
private record ChannelIndexKey(int ChannelId);
private record ChannelIndexRecord(long StartTicks, long Index);
}
}
@@ -2,7 +2,7 @@
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(string channelNumber) : base(channelNumber)
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
{
}
}
+10 -6
View File
@@ -57,14 +57,18 @@ namespace ErsatzTV.Application.Television
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby))
.IfNone(string.Empty));
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
new(
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode)
{
Option<EpisodeMetadata> maybeMetadata = episode.EpisodeMetadata.HeadOrNone();
return new TelevisionEpisodeViewModel(
episode.Season.ShowId,
episode.SeasonId,
episode.EpisodeNumber,
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
maybeMetadata.Map(em => em.EpisodeNumber).IfNone(0),
maybeMetadata.Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
maybeMetadata.Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
maybeMetadata.Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
}
private static string GetPoster(
Metadata metadata,
@@ -40,7 +40,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -76,7 +76,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -112,7 +112,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -151,7 +151,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -317,7 +317,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
version,
new MediaStream(),
@@ -426,7 +426,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
@@ -718,7 +718,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
version,
new MediaStream(),
@@ -89,20 +89,11 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException();
public Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) => throw new NotSupportedException();
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
throw new NotSupportedException();
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<int> GetShowCount() => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException();
public Task<bool> Update(Season season) => throw new NotSupportedException();
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
}
}
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
@@ -16,35 +15,33 @@ namespace ErsatzTV.Core.Tests.Metadata
private FallbackMetadataProvider _fallbackMetadataProvider;
[Test]
[TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s01e02.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2.mkv", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", 1, 2)]
[TestCase(
"Awesome Show (2021) - S01E02 - Description; More Description (1080p QUALITY codec GROUP).mkv",
"Awesome Show (2021)",
1,
2)]
[TestCase(
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
"Awesome.Show",
1,
2)]
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode)
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, int season, int episode)
{
(EpisodeMetadata metadata, int episodeNumber) = _fallbackMetadataProvider.GetFallbackMetadata(
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
new Episode
{
LibraryPath = new LibraryPath(),
@@ -60,10 +57,41 @@ namespace ErsatzTV.Core.Tests.Metadata
}
});
metadata.Title.Should().Be(title);
metadata.Count.Should().Be(1);
// TODO: how can we test season number? do we need to?
// metadata.Season.Should().Be(season);
episodeNumber.Should().Be(episode);
metadata.Head().EpisodeNumber.Should().Be(episode);
}
[Test]
[TestCase("Awesome Show - s01e02-s01e03.mkv", 1, 2, 3)]
[TestCase("Awesome Show - s01e02-whatever-s01e03-whatever2.mkv", 1, 2, 3)]
[TestCase("Awesome Show - s01e02e03.mkv", 1, 2, 3)]
[TestCase("Awesome Show - s01e02-03.mkv", 1, 2, 3)]
public void GetFallbackMetadata_Should_Handle_Two_Episode_Formats(
string path,
int season,
int episode1,
int episode2)
{
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
new Episode
{
LibraryPath = new LibraryPath(),
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
}
}
});
metadata.Count.Should().Be(2);
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
}
}
}
@@ -0,0 +1,239 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo
{
[TestFixture]
public class EpisodeNfoReaderTests
{
[Test]
public async Task One()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
}
[Test]
public async Task Two()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<showtitle>show</showtitle>
<title>episode-one</title>
<episode>1</episode>
<season>1</season>
</episodedetails>
<episodedetails>
<showtitle>show</showtitle>
<title>episode-two</title>
<episode>2</episode>
<season>1</season>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.All(nfo => nfo.ShowTitle == "show").Should().BeTrue();
result.All(nfo => nfo.Season == 1).Should().BeTrue();
result.Count(nfo => nfo.Title == "episode-one" && nfo.Episode == 1).Should().Be(1);
result.Count(nfo => nfo.Title == "episode-two" && nfo.Episode == 2).Should().Be(1);
}
[Test]
public async Task UniqueIds()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<uniqueid default=""true"" type=""tvdb"">12345</uniqueid>
<uniqueid default=""false"" type=""imdb"">tt54321</uniqueid>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].UniqueIds.Count.Should().Be(2);
result[0].UniqueIds.Count(id => id.Default && id.Type == "tvdb" && id.Guid == "12345").Should().Be(1);
result[0].UniqueIds.Count(id => !id.Default && id.Type == "imdb" && id.Guid == "tt54321").Should().Be(1);
}
[Test]
public async Task No_ContentRating()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<mpaa/>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].ContentRating.Should().BeNullOrEmpty();
}
[Test]
public async Task ContentRating()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<mpaa>US:Something</mpaa>
</episodedetails>
<episodedetails>
<mpaa>US:Something / US:SomethingElse</mpaa>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1);
result.Count(nfo => nfo.ContentRating == "US:Something / US:SomethingElse").Should().Be(1);
}
[Test]
public async Task No_Plot()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<plot/>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].Plot.Should().BeNullOrEmpty();
}
[Test]
public async Task Plot()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<plot>Some Plot</plot>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].Plot.Should().Be("Some Plot");
}
[Test]
public async Task Actors()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<actor>
<name>Name 1</name>
<role>Role 1</role>
<thumb>Thumb 1</thumb>
</actor>
<actor>
<name>Name 2</name>
<role>Role 2</role>
<thumb>Thumb 2</thumb>
</actor>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].Actors.Count.Should().Be(2);
result[0].Actors.Count(a => a.Name == "Name 1" && a.Role == "Role 1" && a.Thumb == "Thumb 1")
.Should().Be(1);
result[0].Actors.Count(a => a.Name == "Name 2" && a.Role == "Role 2" && a.Thumb == "Thumb 2")
.Should().Be(1);
}
[Test]
public async Task Writers()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<credits>Writer 1</credits>
</episodedetails>
<episodedetails>
<credits>Writer 2</credits>
<credits>Writer 3</credits>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1);
result.Count(nfo => nfo.Writers.Count == 2 && nfo.Writers[0] == "Writer 2" && nfo.Writers[1] == "Writer 3")
.Should().Be(1);
}
[Test]
public async Task Directors()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<director>Director 1</director>
</episodedetails>
<episodedetails>
<director>Director 2</director>
<director>Director 3</director>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1);
result.Count(
nfo => nfo.Directors.Count == 2 && nfo.Directors[0] == "Director 2" &&
nfo.Directors[1] == "Director 3")
.Should().Be(1);
}
}
}
@@ -60,6 +60,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
}
[Test]
[Timeout(1000)]
public void State_Should_Reset_When_Invalid()
{
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10 };
var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state);
chronologicalContent.State.Index.Should().Be(0);
chronologicalContent.State.Seed.Should().Be(0);
}
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => (MediaItem) new Episode
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[TestCase("Episode 1", "Episode 2 (1)", "Episode 3 (2)", "Episode 4")]
[TestCase("Episode 1 - More", "Episode 2 (1) - Title", "Episode 3 (2) - After", "Episode 4 - Dash")]
[TestCase("Episode 1", "Episode 2 Part 1", "Episode 3 Part 2", "Episode 4")]
[TestCase("Episode 1", "Episode 2 (Part 1)", "Episode 3 (Part 2)", "Episode 4")]
public void NotGrouped_Grouped_NotGrouped(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
@@ -24,7 +25,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
@@ -32,10 +33,54 @@ namespace ErsatzTV.Core.Tests.Scheduling
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 2 - Part 2", "Episode 3")]
[TestCase("Episode 1 Part 1", "Episode 2 (2) - More", "Episode 3 - After")]
[TestCase("Episode 1 Part 1", "Episode 2 (II)", "Episode 3")]
[TestCase("Episode 1 Part One", "Episode 2 (II)", "Episode 3")]
[TestCase("Episode 1 (1)", "Episode 2 (Part 2)", "Episode 3")]
public void MixedNaming_Group(string one, string two, string three)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[2]);
}
[Test]
[TestCase("Episode 1 (5)", "Episode 2 - (6)", "Episode 3")]
[TestCase("Episode 1 Part 5", "Episode 2 Part 6", "Episode 3 - After")]
[TestCase("Episode 1 Part (V)", "Episode 2 (VI)", "Episode 3")]
[TestCase("Episode 1 (Part 5)", "Episode 2 (Part 6)", "Episode 3")]
public void Only_Later_Parts(string one, string two, string three)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[2]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 - After")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3")]
[TestCase("Episode 1 (Part 1)", "Episode 2 (Part 2)", "Episode 3")]
public void Grouped_NotGrouped(string one, string two, string three)
{
var mediaItems = new List<MediaItem>
@@ -45,7 +90,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(three, 1, 1, 3)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@@ -61,6 +106,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
"Episode 4 (1) - Dash",
"Episode 5 (2) - Again")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3", "Episode 4 Part 1", "Episode 5 Part 2")]
[TestCase("Episode 1 (Part 1)", "Episode 2 (Part 2)", "Episode 3", "Episode 4 (Part 1)", "Episode 5 (Part 2)")]
public void Grouped_NotGrouped_Grouped(string one, string two, string three, string four, string five)
{
var mediaItems = new List<MediaItem>
@@ -72,7 +118,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(five, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@@ -84,6 +130,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3 (1)", "Episode 4 (2)")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 (1) - After", "Episode 4 (2) - Dash")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3 Part 1", "Episode 4 Part 2")]
[TestCase("Episode 1 (Part 1)", "Episode 2 (Part 2)", "Episode 3 (Part 1)", "Episode 4 (Part 2)")]
public void Grouped_Grouped(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
@@ -94,7 +141,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@@ -105,6 +152,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[TestCase("Episode 1", "Episode 2 (2)", "Episode 3 (1)", "Episode 4 (2)")]
[TestCase("Episode 1 - More", "Episode 2 (2) - Title", "Episode 3 (1) - After", "Episode 4 (2) - Dash")]
[TestCase("Episode 1", "Episode 2 Part 2", "Episode 3 Part 1", "Episode 4 Part 2")]
[TestCase("Episode 1", "Episode 2 (Part 2)", "Episode 3 (Part 1)", "Episode 4 (Part 2)")]
public void Part2_Without_Part1(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
@@ -115,7 +163,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
@@ -123,10 +171,34 @@ namespace ErsatzTV.Core.Tests.Scheduling
ShouldHaveTwoItems(result, mediaItems[2], mediaItems[3]);
}
[Test]
[TestCase("Episode 1", "Episode 2 (2)", "Episode 3 (3)", "Episode 4")]
[TestCase("Episode 1 - More", "Episode 2 (2) - Title", "Episode 3 (3) - After", "Episode 4 - Dash")]
[TestCase("Episode 1", "Episode 2 Part 2", "Episode 3 Part 3", "Episode 4")]
[TestCase("Episode 1", "Episode 2 (Part 2)", "Episode 3 (Part 3)", "Episode 4")]
public void Part2And3_Without_Part1(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3),
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
ShouldHaveTwoItems(result, mediaItems[1], mediaItems[2]);
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 3 (3)", "Episode 4", "Episode 5")]
[TestCase("Episode 1 (1) - More", "Episode 3 (3) - Title", "Episode 4 - After", "Episode 5 - Dash")]
[TestCase("Episode 1 Part 1", "Episode 3 Part 3", "Episode 4", "Episode 5")]
[TestCase("Episode 1 (Part 1)", "Episode 3 (Part 3)", "Episode 4", "Episode 5")]
public void Skip_Part(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
@@ -137,7 +209,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(4);
ShouldHaveOneItem(result, mediaItems[0]);
@@ -150,6 +222,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[TestCase("Episode 1 (1)", "Episode 3 (1)", "Episode 4 (2)", "Episode 5")]
[TestCase("Episode 1 (1) - More", "Episode 3 (1) - Title", "Episode 4 (2) - After", "Episode 5 - Dash")]
[TestCase("Episode 1 Part 1", "Episode 3 Part 1", "Episode 4 Part 2", "Episode 5")]
[TestCase("Episode 1 (Part 1)", "Episode 3 (Part 1)", "Episode 4 (Part 2)", "Episode 5")]
public void Repeat_Part(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
@@ -160,7 +233,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
@@ -176,6 +249,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
"S1 Episode 2 (2) - After",
"S1 Episode 5 - Dash")]
[TestCase("S1 Episode 1 Part 1", "S2 Episode 3 Part 1", "S1 Episode 2 Part 2", "S1 Episode 5")]
[TestCase("S1 Episode 1 (Part 1)", "S2 Episode 3 (Part 1)", "S1 Episode 2 (Part 2)", "S1 Episode 5")]
public void Mixed_Shows_Chronologically(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
@@ -186,7 +260,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5, new DateTime(2020, 1, 4))
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[2]);
@@ -194,6 +268,32 @@ namespace ErsatzTV.Core.Tests.Scheduling
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test]
[TestCase("S1 Episode 1 (1)", "S2 Episode 3 (2)", "S1 Episode 2 (3)", "S1 Episode 5")]
[TestCase(
"S1 Episode 1 (1) - More",
"S2 Episode 3 (2) - Title",
"S1 Episode 2 (3) - After",
"S1 Episode 5 - Dash")]
[TestCase("S1 Episode 1 Part 1", "S2 Episode 3 Part 2", "S1 Episode 2 Part 3", "S1 Episode 5")]
[TestCase("S1 Episode 1 (Part 1)", "S2 Episode 3 (Part 2)", "S1 Episode 2 (Part 3)", "S1 Episode 5")]
public void Mixed_Shows_Chronologically_Crossover(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1, new DateTime(2020, 1, 1)),
NamedEpisode(two, 2, 1, 3, new DateTime(2020, 1, 2)),
NamedEpisode(three, 1, 1, 2, new DateTime(2020, 1, 3)),
NamedEpisode(four, 1, 1, 5, new DateTime(2020, 1, 4))
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, true);
result.Count.Should().Be(2);
ShouldHaveMultipleItems(result, mediaItems[0], new List<MediaItem> { mediaItems[1], mediaItems[2] });
ShouldHaveOneItem(result, mediaItems[3]);
}
private static Episode NamedEpisode(
string title,
int showId,
@@ -202,10 +302,9 @@ namespace ErsatzTV.Core.Tests.Scheduling
DateTime? releaseDate = null) =>
new()
{
EpisodeNumber = episode,
EpisodeMetadata = new List<EpisodeMetadata>
{
new() { Title = title, ReleaseDate = releaseDate }
new() { Title = title, ReleaseDate = releaseDate, EpisodeNumber = episode }
},
Season = new Season
{
@@ -225,5 +324,14 @@ namespace ErsatzTV.Core.Tests.Scheduling
MediaItem additional) =>
result.Filter(g => g.First == first && Optional(g.Additional).Flatten().HeadOrNone() == Some(additional))
.Should().HaveCount(1);
private static void ShouldHaveMultipleItems(
IEnumerable<GroupedMediaItem> result,
MediaItem first,
List<MediaItem> additional) =>
result.Filter(
g => g.First == first && g.Additional != null && g.Additional.Count == additional.Count &&
additional.ForAll(g.Additional.Contains))
.Should().HaveCount(1);
}
}
@@ -39,7 +39,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
[Test]
[Timeout(2000)]
public async Task ZeroDurationItem_Should_Abort()
public async Task OnlyZeroDurationItem_Should_Abort()
{
var mediaItems = new List<MediaItem>
{
@@ -55,6 +55,27 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Items.Should().BeNull();
}
[Test]
public async Task ZeroDurationItem_Should_BeSkipped()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.Zero, DateTime.Today),
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
};
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(1);
result.Items.Head().MediaItemId.Should().Be(2);
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
}
[Test]
public async Task InitialFlood_Should_StartAtMidnight()
{
@@ -78,6 +78,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
}
[Test]
[Timeout(1000)]
public void State_Index_Should_Continue_Past_End_Of_Items()
{
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10, Seed = KnownSeed };
var _ = new RandomizedMediaCollectionEnumerator(contents, state);
}
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => (MediaItem) new Episode
@@ -23,7 +23,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
var state = new CollectionEnumeratorState { Seed = 8 };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 1000; i++)
@@ -50,7 +50,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@@ -70,7 +70,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@@ -90,7 +90,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
for (var i = 0; i < 10; i++)
{
@@ -105,7 +105,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
for (var i = 6; i <= 10; i++)
{
@@ -116,6 +116,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
}
[Test]
[Timeout(1000)]
public void State_Should_Reset_When_Invalid()
{
List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
shuffledContent.State.Index.Should().Be(0);
shuffledContent.State.Seed.Should().NotBe(MagicSeed);
}
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => (MediaItem) new Episode
+1
View File
@@ -14,6 +14,7 @@
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
}
@@ -6,7 +6,6 @@ namespace ErsatzTV.Core.Domain
[DebuggerDisplay("{EpisodeMetadata[0].Title}")]
public class Episode : MediaItem
{
public int EpisodeNumber { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; }
public List<EpisodeMetadata> EpisodeMetadata { get; set; }
@@ -4,6 +4,7 @@ namespace ErsatzTV.Core.Domain
{
public class EpisodeMetadata : Metadata
{
public int EpisodeNumber { get; set; }
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
+1
View File
@@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public List<ProgramScheduleItem> Items { get; set; }
public List<Playout> Playouts { get; set; }
}
+1 -1
View File
@@ -3,6 +3,6 @@
public enum StreamingMode
{
TransportStream = 1,
HttpLiveStreaming = 2
HttpLiveStreamingDirect = 2
}
}
@@ -335,7 +335,7 @@ namespace ErsatzTV.Core.Emby
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeNumber);
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true;
incoming.SeasonId = season.Id;
@@ -370,7 +370,7 @@ namespace ErsatzTV.Core.Emby
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeNumber);
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming))
{
@@ -64,7 +64,7 @@ namespace ErsatzTV.Core.FFmpeg
switch (streamingMode)
{
case StreamingMode.HttpLiveStreaming:
case StreamingMode.HttpLiveStreamingDirect:
result.AudioCodec = "copy";
result.VideoCodec = "copy";
result.Deinterlace = false;
+1 -1
View File
@@ -28,7 +28,7 @@ namespace ErsatzTV.Core.FFmpeg
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming &&
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
{
_logger.LogDebug(
+1 -1
View File
@@ -22,7 +22,7 @@ namespace ErsatzTV.Core.Hdhr
public string URL => _channel.StreamingMode switch
{
StreamingMode.HttpLiveStreaming => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
StreamingMode.HttpLiveStreamingDirect => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
};
}
@@ -1,4 +1,4 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using LanguageExt;
@@ -8,7 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
ShowMetadata GetFallbackMetadataForShow(string showFolder);
ArtistMetadata GetFallbackMetadataForArtist(string artistFolder);
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
List<EpisodeMetadata> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
string GetSortTitle(string title);
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo
{
public interface IEpisodeNfoReader
{
Task<List<TvShowEpisodeNfo>> Read(Stream input);
}
}
@@ -47,7 +47,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
}
+2 -1
View File
@@ -182,7 +182,8 @@ namespace ErsatzTV.Core.Iptv
if (!isSameCustomShow)
{
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
int e = episode.EpisodeNumber;
// TODO: multi-episode?
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
if (s > 0 && e > 0)
{
xml.WriteStartElement("episode-num");
+1 -1
View File
@@ -42,7 +42,7 @@ namespace ErsatzTV.Core.Iptv
string format = channel.StreamingMode switch
{
StreamingMode.HttpLiveStreaming => "m3u8",
StreamingMode.HttpLiveStreamingDirect => "m3u8",
_ => "ts"
};
@@ -336,7 +336,7 @@ namespace ErsatzTV.Core.Jellyfin
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeNumber);
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true;
incoming.SeasonId = season.Id;
@@ -371,7 +371,7 @@ namespace ErsatzTV.Core.Jellyfin
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeNumber);
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming))
{
@@ -26,13 +26,28 @@ namespace ErsatzTV.Core.Metadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
}
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
public List<EpisodeMetadata> GetFallbackMetadata(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
var metadata = new EpisodeMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow };
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
var baseMetadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
DateAdded = DateTime.UtcNow,
EpisodeNumber = 0,
Actors = new List<Actor>(),
Artwork = new List<Artwork>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Guids = new List<MetadataGuid>(),
Studios = new List<Studio>(),
Tags = new List<Tag>(),
Writers = new List<Writer>()
};
return fileName != null
? GetEpisodeMetadata(fileName, baseMetadata)
: new List<EpisodeMetadata> { baseMetadata };
}
public MovieMetadata GetFallbackMetadata(Movie movie)
@@ -87,18 +102,47 @@ namespace ErsatzTV.Core.Metadata
return title;
}
private Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
private static List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
{
var result = new List<EpisodeMetadata>();
try
{
const string PATTERN = @"^(.*?)[.\s-]+[sS](\d+)[eE](\d+).*\.\w+$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
const string PATTERN = @"[sS]\d+[eE]([e\-\d{1,2}]+)";
MatchCollection matches = Regex.Matches(fileName, PATTERN);
if (matches.Count > 0)
{
metadata.Title = match.Groups[1].Value;
metadata.DateUpdated = DateTime.UtcNow;
metadata.Actors = new List<Actor>();
return Tuple(metadata, int.Parse(match.Groups[3].Value));
foreach (Match match in matches)
{
string[] split = match.Groups[1].Value.Replace('e', '-').Split('-');
foreach (string ep in split)
{
if (!int.TryParse(ep, out int episodeNumber))
{
continue;
}
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Fallback,
EpisodeNumber = episodeNumber,
DateAdded = baseMetadata.DateAdded,
DateUpdated = baseMetadata.DateAdded,
Actors = new List<Actor>(),
Artwork = new List<Artwork>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Guids = new List<MetadataGuid>(),
Studios = new List<Studio>(),
Tags = new List<Tag>(),
Writers = new List<Writer>()
};
result.Add(metadata);
}
}
return result;
}
}
catch (Exception)
@@ -106,7 +150,7 @@ namespace ErsatzTV.Core.Metadata
// ignored
}
return Tuple(metadata, 0);
return result;
}
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
+145 -116
View File
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata.Nfo;
using LanguageExt;
@@ -17,11 +18,11 @@ namespace ErsatzTV.Core.Metadata
public class LocalMetadataProvider : ILocalMetadataProvider
{
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository;
private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
@@ -39,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
IEpisodeNfoReader episodeNfoReader,
ILogger<LocalMetadataProvider> logger)
{
_metadataRepository = metadataRepository;
@@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_episodeNfoReader = episodeNfoReader;
_logger = logger;
}
@@ -110,10 +113,7 @@ namespace ErsatzTV.Core.Metadata
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
LoadEpisodeMetadata(episode, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(episode, metadata),
() => Task.FromResult(false)));
LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
@@ -174,118 +174,138 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
{
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
if (episode.EpisodeNumber != episodeNumber)
var updated = false;
episode.EpisodeMetadata ??= new List<EpisodeMetadata>();
var toUpdate = episode.EpisodeMetadata
.Where(em => episodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
.ToList();
var toRemove = episode.EpisodeMetadata.Except(toUpdate).ToList();
var toAdd = episodeMetadata
.Where(em => episode.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
.ToList();
foreach (EpisodeMetadata metadata in toRemove)
{
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
await _televisionRepository.RemoveMetadata(episode, metadata);
updated = true;
}
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
foreach (EpisodeMetadata metadata in toAdd)
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.EpisodeId = episode.Id;
metadata.Episode = episode;
episode.EpisodeMetadata.Add(metadata);
if (existing.DateAdded == DateTime.MinValue)
updated = await _metadataRepository.Add(metadata) || updated;
}
foreach (EpisodeMetadata metadata in toUpdate)
{
Option<EpisodeMetadata> maybeExisting =
episode.EpisodeMetadata.Find(em => em.EpisodeNumber == metadata.EpisodeNumber);
updated = await maybeExisting.Match(
async existing =>
{
existing.DateAdded = metadata.DateAdded;
}
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
if (existing.DateAdded == DateTime.MinValue)
{
updated = true;
existing.DateAdded = metadata.DateAdded;
}
}
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor) || updated;
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
updated = true;
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
}
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
updated = true;
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
{
updated = true;
}
}
}
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
updated = true;
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
updated = true;
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
{
updated = true;
}
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
updated = true;
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.EpisodeId = episode.Id;
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Add(metadata);
});
return await _metadataRepository.Update(existing) || updated;
},
() => Task.FromResult(updated)) || updated;
}
return true;
return updated;
}
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
@@ -665,36 +685,45 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
private async Task<List<EpisodeMetadata>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>(
nfo =>
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
List<TvShowEpisodeNfo> nfos = await _episodeNfoReader.Read(fileStream);
var result = new List<EpisodeMetadata>();
foreach (TvShowEpisodeNfo nfo in nfos)
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList(),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList()
};
return Tuple(metadata, nfo.Episode);
},
None);
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(nfo.Title),
EpisodeNumber = nfo.Episode,
Year = GetYear(0, nfo.Aired),
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList(),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Artwork = new List<Artwork>()
};
result.Add(metadata);
}
return result;
}
catch (Exception ex)
{
@@ -883,7 +912,7 @@ namespace ErsatzTV.Core.Metadata
return updated;
}
private List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
private static List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
{
var result = new List<Actor>();
@@ -0,0 +1,201 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
namespace ErsatzTV.Core.Metadata.Nfo
{
public class EpisodeNfoReader : IEpisodeNfoReader
{
public async Task<List<TvShowEpisodeNfo>> Read(Stream input)
{
var result = new List<TvShowEpisodeNfo>();
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
using var reader = XmlReader.Create(input, settings);
TvShowEpisodeNfo nfo = null;
while (await reader.ReadAsync())
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
switch (reader.Name.ToLowerInvariant())
{
case "episodedetails":
nfo = new TvShowEpisodeNfo
{
UniqueIds = new List<UniqueIdNfo>(),
Actors = new List<ActorNfo>(),
Writers = new List<string>(),
Directors = new List<string>()
};
break;
case "title":
await ReadTitle(reader, nfo);
break;
case "showtitle":
await ReadShowTitle(reader, nfo);
break;
case "episode":
await ReadEpisode(reader, nfo);
break;
case "season":
await ReadSeason(reader, nfo);
break;
case "uniqueid":
await ReadUniqueId(reader, nfo);
break;
case "mpaa":
await ReadContentRating(reader, nfo);
break;
case "aired":
// TODO: parse the date here
await ReadAired(reader, nfo);
break;
case "plot":
await ReadPlot(reader, nfo);
break;
case "actor":
ReadActor(reader, nfo);
break;
case "credits":
await ReadWriter(reader, nfo);
break;
case "director":
await ReadDirector(reader, nfo);
break;
}
break;
case XmlNodeType.EndElement:
switch (reader.Name.ToLowerInvariant())
{
case "episodedetails":
if (nfo != null)
{
result.Add(nfo);
}
break;
}
break;
}
}
return result;
}
private static async Task ReadTitle(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Title = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadShowTitle(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.ShowTitle = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadEpisode(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int episode);
nfo.Episode = episode;
}
}
private static async Task ReadSeason(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int season);
nfo.Season = season;
}
}
private static async Task ReadUniqueId(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
var uniqueId = new UniqueIdNfo();
reader.MoveToAttribute("default");
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
reader.MoveToAttribute("type");
uniqueId.Type = reader.Value;
reader.MoveToElement();
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
nfo.UniqueIds.Add(uniqueId);
}
}
private static async Task ReadContentRating(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.ContentRating = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadAired(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Aired = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadPlot(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Plot = await reader.ReadElementContentAsStringAsync();
}
}
private static void ReadActor(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
var actor = new ActorNfo();
var element = (XElement) XNode.ReadFrom(reader);
XElement name = element.Element("name");
if (name != null)
{
actor.Name = name.Value;
}
XElement role = element.Element("role");
if (role != null)
{
actor.Role = role.Value;
}
XElement thumb = element.Element("thumb");
if (thumb != null)
{
actor.Thumb = thumb.Value;
}
nfo.Actors.Add(actor);
}
}
private static async Task ReadWriter(XmlReader reader, TvShowEpisodeNfo nfo) =>
nfo?.Writers.Add(await reader.ReadElementContentAsStringAsync());
private static async Task ReadDirector(XmlReader reader, TvShowEpisodeNfo nfo) =>
nfo?.Directors.Add(await reader.ReadElementContentAsStringAsync());
}
}
@@ -24,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
@@ -49,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_libraryRepository = libraryRepository;
@@ -169,6 +171,7 @@ namespace ErsatzTV.Core.Metadata
{
Either<BaseError, Season> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
.BindT(EnsureMetadataExists)
.BindT(season => UpdatePoster(season, seasonFolder));
await maybeSeason.Match(
@@ -270,8 +273,28 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, Episode>> UpdateMetadata(
Episode episode)
private async Task<Either<BaseError, Season>> EnsureMetadataExists(Season season)
{
season.SeasonMetadata ??= new List<SeasonMetadata>();
if (!season.SeasonMetadata.Any())
{
var metadata = new SeasonMetadata
{
SeasonId = season.Id,
Season = season,
DateAdded = DateTime.UtcNow,
Guids = new List<MetadataGuid>()
};
season.SeasonMetadata.Add(metadata);
await _metadataRepository.Add(metadata);
}
return season;
}
private async Task<Either<BaseError, Episode>> UpdateMetadata(Episode episode)
{
try
{
@@ -360,8 +383,10 @@ namespace ErsatzTV.Core.Metadata
await LocateThumbnail(episode).IfSomeAsync(
async posterFile =>
{
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
}
});
return episode;
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
@@ -375,8 +376,9 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexEpisode> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateMetadataAndStatistics(
existing => UpdateStatistics(
existing,
incoming,
plexMediaSourceLibrary,
@@ -417,7 +419,36 @@ namespace ErsatzTV.Core.Plex
});
}
private async Task<Either<BaseError, PlexEpisode>> UpdateMetadataAndStatistics(
private async Task<Either<BaseError, PlexEpisode>> UpdateMetadata(PlexEpisode existing, PlexEpisode incoming)
{
var toUpdate = existing.EpisodeMetadata
.Where(em => incoming.EpisodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
.ToList();
var toRemove = existing.EpisodeMetadata.Except(toUpdate).ToList();
var toAdd = incoming.EpisodeMetadata
.Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
.ToList();
foreach (EpisodeMetadata metadata in toRemove)
{
await _televisionRepository.RemoveMetadata(existing, metadata);
}
foreach (EpisodeMetadata metadata in toAdd)
{
metadata.EpisodeId = existing.Id;
metadata.Episode = existing;
existing.EpisodeMetadata.Add(metadata);
await _metadataRepository.Add(metadata);
}
// TODO: update existing metadata
return existing;
}
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
PlexEpisode existing,
PlexEpisode incoming,
PlexLibrary library,
@@ -441,22 +472,25 @@ namespace ErsatzTV.Core.Plex
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
foreach (EpisodeMetadata existingMetadata in maybeExisting)
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
@@ -471,17 +505,21 @@ namespace ErsatzTV.Core.Plex
return Right<BaseError, PlexEpisode>(existing);
}
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(
PlexEpisode existing,
PlexEpisode incoming)
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(PlexEpisode existing, PlexEpisode incoming)
{
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
foreach (EpisodeMetadata incomingMetadata in incoming.EpisodeMetadata)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
Option<EpisodeMetadata> maybeExistingMetadata = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
if (maybeExistingMetadata.IsSome)
{
EpisodeMetadata existingMetadata = maybeExistingMetadata.ValueUnsafe();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
}
}
return existing;
@@ -18,6 +18,13 @@ namespace ErsatzTV.Core.Scheduling
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalMediaComparer()).ToList();
State = new CollectionEnumeratorState { Seed = state.Seed };
if (state.Index >= _sortedMediaItems.Count)
{
state.Index = 0;
state.Seed = 0;
}
while (State.Index < state.Index)
{
MoveNext();
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Scheduling
@@ -65,13 +66,13 @@ namespace ErsatzTV.Core.Scheduling
int episode1 = x switch
{
Episode e => e.EpisodeNumber,
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
_ => int.MaxValue
};
int episode2 = y switch
{
Episode e => e.EpisodeNumber,
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
_ => int.MaxValue
};
@@ -3,21 +3,33 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
public static class MultiPartEpisodeGrouper
{
public static List<GroupedMediaItem> GroupMediaItems(IList<MediaItem> mediaItems)
public static List<GroupedMediaItem> GroupMediaItems(IList<MediaItem> mediaItems, bool treatCollectionsAsShows)
{
var episodes = mediaItems.OfType<Episode>().ToList();
var showIds = episodes.Map(e => e.Season.ShowId).Distinct().ToList();
// var showIds = episodes.Map(e => e.Season.ShowId).Distinct().ToList();
var groups = new List<GroupedMediaItem>();
GroupedMediaItem group = null;
foreach (int showId in showIds)
var showIds = new List<Option<int>>();
if (treatCollectionsAsShows)
{
showIds.Add(Option<int>.None);
}
else
{
showIds.AddRange(episodes.Map(e => Some(e.Season.ShowId)).Distinct());
}
foreach (Option<int> showId in showIds)
{
var lastNumber = 0;
@@ -33,13 +45,16 @@ namespace ErsatzTV.Core.Scheduling
groups.Add(new GroupedMediaItem(item, null));
}
foreach (Episode episode in episodes.Filter(e => e.Season.ShowId == showId)
.OrderBy(identity, new ChronologicalMediaComparer()))
IEnumerable<Episode> sortedEpisodes = showId.Match(
id => episodes.Filter(e => e.Season.ShowId == id),
() => episodes).OrderBy(identity, new ChronologicalMediaComparer());
foreach (Episode episode in sortedEpisodes)
{
string numberString = FindPartNumber(episode);
if (numberString != null)
Option<int> maybeNumber = FindPartNumber(episode);
if (maybeNumber.IsSome)
{
var number = int.Parse(numberString);
int number = maybeNumber.ValueUnsafe();
if (number <= lastNumber && group != null)
{
groups.Add(group);
@@ -47,28 +62,36 @@ namespace ErsatzTV.Core.Scheduling
lastNumber = 0;
}
if (number == lastNumber + 1)
if (number > lastNumber)
{
if (lastNumber == 0)
{
// start a new group
group = new GroupedMediaItem(episode, null);
lastNumber = number;
}
else if (group != null)
else if (number == lastNumber + 1)
{
// add to current group
List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
additional.Add(episode);
group = group with { Additional = additional };
if (group != null)
{
// add to current group
List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
additional.Add(episode);
group = group with { Additional = additional };
}
else
{
// this should never happen
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber} with no existing group");
}
lastNumber = number;
}
else
{
// this should never happen
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber} with no existing group");
AddUngrouped(episode);
}
lastNumber = number;
}
else
{
@@ -96,18 +119,37 @@ namespace ErsatzTV.Core.Scheduling
return groups;
}
private static string FindPartNumber(Episode e)
private static Option<int> FindPartNumber(Episode e)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success)
if (match.Success && int.TryParse(match.Groups[1].Value, out int value1))
{
return match.Groups[1].Value;
return value1;
}
const string PATTERN_2 = @"^.*Part (\d+)$";
const string PATTERN_2 = @"^.*\(?Part (\d+)\)?$";
Match match2 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_2);
return match2.Success ? match2.Groups[1].Value : null;
if (match2.Success && int.TryParse(match2.Groups[1].Value, out int value2))
{
return value2;
}
const string PATTERN_3 = @"^.*\(([MDCLXVI]+)\)( - .*)?$";
Match match3 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_3);
if (match3.Success && TryParseRoman(match3.Groups[1].Value, out int value3))
{
return value3;
}
const string PATTERN_4 = @"^.*Part (\w+)$";
Match match4 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_4);
if (match4.Success && TryParseEnglish(match4.Groups[1].Value, out int value4))
{
return value4;
}
return None;
}
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount)
@@ -125,5 +167,85 @@ namespace ErsatzTV.Core.Scheduling
return result;
}
private static bool TryParseRoman(string input, out int output)
{
switch (input?.ToLowerInvariant())
{
case "i":
output = 1;
return true;
case "ii":
output = 2;
return true;
case "iii":
output = 3;
return true;
case "iv":
output = 4;
return true;
case "v":
output = 5;
return true;
case "vi":
output = 6;
return true;
case "vii":
output = 7;
return true;
case "viii" or "iix":
output = 8;
return true;
case "ix":
output = 9;
return true;
case "x":
output = 10;
return true;
default:
output = 0;
return false;
}
}
private static bool TryParseEnglish(string input, out int output)
{
switch (input?.ToLowerInvariant())
{
case "one":
output = 1;
return true;
case "two":
output = 2;
return true;
case "three":
output = 3;
return true;
case "four":
output = 4;
return true;
case "five":
output = 5;
return true;
case "six":
output = 6;
return true;
case "seven":
output = 7;
return true;
case "eight":
output = 8;
return true;
case "nine":
output = 9;
return true;
case "ten":
output = 10;
return true;
default:
output = 0;
return false;
}
}
}
}
+40 -4
View File
@@ -80,17 +80,52 @@ namespace ErsatzTV.Core.Scheduling
playout.Channel.Number,
playout.Channel.Name);
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
{
var zeroItems = new List<MediaItem>();
foreach (MediaItem item in items)
{
bool isZero = item switch
{
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone().IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
_ => true
};
if (isZero)
{
_logger.LogWarning(
"Skipping media item with zero duration {MediaItem} - {MediaItemTitle}",
item.Id,
DisplayTitle(item));
zeroItems.Add(item);
}
}
items.RemoveAll(i => zeroItems.Contains(i));
}
// this guard needs to be below the place where we modify the collections (by removing zero-duration items)
Option<CollectionKey> emptyCollection =
collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
if (emptyCollection.IsSome)
{
_logger.LogError(
"Unable to rebuild playout; collection {@CollectionKey} has no items!",
"Unable to rebuild playout; collection {@CollectionKey} has no valid items!",
emptyCollection.ValueUnsafe());
return playout;
}
// leaving this guard in for a while to ensure the zero item removal is working properly
Option<CollectionKey> zeroDurationCollection = collectionMediaItems.Find(
c => c.Value.Any(
mi => mi switch
@@ -106,7 +141,7 @@ namespace ErsatzTV.Core.Scheduling
if (zeroDurationCollection.IsSome)
{
_logger.LogError(
"Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
"BUG: Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
zeroDurationCollection.ValueUnsafe());
return playout;
@@ -471,7 +506,8 @@ namespace ErsatzTV.Core.Scheduling
return new ShuffledMediaCollectionEnumerator(
mediaItems,
state,
playout.ProgramSchedule.KeepMultiPartEpisodesTogether);
playout.ProgramSchedule.KeepMultiPartEpisodesTogether,
playout.ProgramSchedule.TreatCollectionsAsShows);
default:
// TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
@@ -486,7 +522,7 @@ namespace ErsatzTV.Core.Scheduling
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{em.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]");
@@ -18,14 +18,21 @@ namespace ErsatzTV.Core.Scheduling
public ShuffledMediaCollectionEnumerator(
IList<MediaItem> mediaItems,
CollectionEnumeratorState state,
bool keepMultiPartEpisodesTogether)
bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows)
{
_mediaItemCount = mediaItems.Count;
_mediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems)
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
if (state.Index >= _mediaItems.Count)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();
}
_random = new Random(state.Seed);
_shuffled = Shuffle(_mediaItems, _random);
@@ -423,9 +423,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
episode.Id = existing.Id;
existing.Etag = episode.Etag;
existing.EpisodeNumber = episode.EpisodeNumber;
// metadata
// TODO: multiple metadata?
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
metadata.Title = incomingMetadata.Title;
@@ -435,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
metadata.EpisodeNumber = incomingMetadata.EpisodeNumber;
// thumbnail
Artwork incomingThumbnail =
@@ -423,9 +423,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
episode.Id = existing.Id;
existing.Etag = episode.Etag;
existing.EpisodeNumber = episode.EpisodeNumber;
// metadata
// TODO: multiple metadata?
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
metadata.Title = incomingMetadata.Title;
@@ -435,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
metadata.EpisodeNumber = metadata.EpisodeNumber;
// thumbnail
Artwork incomingThumbnail =
@@ -59,6 +59,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(em => em.Directors)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Writers)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(em => em.Streams)
.Include(mi => (mi as Episode).Season)
@@ -97,6 +97,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.OrderBy(em => em.SortTitle)
.ToListAsync();
}
@@ -197,7 +198,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.OrderBy(em => em.Episode.EpisodeNumber)
.OrderBy(em => em.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
@@ -521,12 +522,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return ids;
}
public async Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber)
public async Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata)
{
episode.EpisodeNumber = episodeNumber;
episode.EpisodeMetadata.Remove(metadata);
await _dbConnection.ExecuteAsync(
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id",
new { EpisodeNumber = episodeNumber, episode.Id });
@"DELETE FROM EpisodeMetadata WHERE Id = @MetadataId",
new { MetadataId = metadata.Id });
return Unit.Default;
}
@@ -805,13 +806,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
item.LibraryPathId = library.Paths.Head().Id;
EpisodeMetadata metadata = item.EpisodeMetadata.Head();
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Directors ??= new List<Director>();
metadata.Writers ??= new List<Writer>();
foreach (EpisodeMetadata metadata in item.EpisodeMetadata)
{
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Directors ??= new List<Director>();
metadata.Writers ??= new List<Writer>();
}
await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync();
@@ -512,11 +512,6 @@ namespace ErsatzTV.Infrastructure.Emby
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
};
if (item.IndexNumber.HasValue)
{
episode.EpisodeNumber = item.IndexNumber.Value;
}
return episode;
}
catch (Exception ex)
@@ -549,6 +544,11 @@ namespace ErsatzTV.Infrastructure.Emby
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
};
if (item.IndexNumber.HasValue)
{
metadata.EpisodeNumber = item.IndexNumber.Value;
}
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
@@ -568,11 +568,6 @@ namespace ErsatzTV.Infrastructure.Jellyfin
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
};
if (item.IndexNumber.HasValue)
{
episode.EpisodeNumber = item.IndexNumber.Value;
}
return episode;
}
catch (Exception ex)
@@ -605,6 +600,11 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
};
if (item.IndexNumber.HasValue)
{
metadata.EpisodeNumber = item.IndexNumber.Value;
}
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ProgramScheduleTreatCollectionsAsShows : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "TreatCollectionsAsShows",
table: "ProgramSchedule",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TreatCollectionsAsShows",
table: "ProgramSchedule");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_EpisodeMetadataEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeNumber",
table: "EpisodeMetadata",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EpisodeNumber",
table: "EpisodeMetadata");
}
}
}
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_EpisodeMetadataEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE EpisodeMetadata SET EpisodeNumber = (SELECT EpisodeNumber FROM Episode WHERE Id = EpisodeMetadata.EpisodeId)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_EpisodeEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EpisodeNumber",
table: "Episode");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeNumber",
table: "Episode",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_LocalSeasonEtag : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL
WHERE LibraryPathId IN
(SELECT MI.LibraryPathId FROM MediaItem MI
INNER JOIN Season S on MI.Id = S.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
INNER JOIN Library L on LP.LibraryId = L.Id
INNER JOIN LocalLibrary LL on L.Id = LL.Id
WHERE L.MediaKind = 2)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -348,6 +348,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("EpisodeId")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
@@ -1056,6 +1059,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("TreatCollectionsAsShows")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
@@ -1418,9 +1424,6 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("SeasonId")
.HasColumnType("INTEGER");
@@ -110,7 +110,8 @@ namespace ErsatzTV.Infrastructure.Plex
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetSeasonChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
.Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)).ToList());
.Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)))
.Map(ProcessMultiEpisodeFiles);
}
catch (Exception ex)
{
@@ -225,6 +226,27 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
private List<PlexEpisode> ProcessMultiEpisodeFiles(IEnumerable<PlexEpisode> episodes)
{
// add all metadata from duplicate paths to first entry with given path
// i.e. s1e1 episode will add s1e2 metadata if s1e1 and s1e2 have same physical path
var result = new Dictionary<string, PlexEpisode>();
foreach (PlexEpisode episode in episodes.OrderBy(e => e.EpisodeMetadata.Head().EpisodeNumber))
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
if (result.TryGetValue(path, out PlexEpisode existing))
{
existing.EpisodeMetadata.Add(episode.EpisodeMetadata.Head());
}
else
{
result.Add(path, episode);
}
}
return result.Values.ToList();
}
private static IPlexServerApi XmlServiceFor(string uri)
{
var overrides = new XmlAttributeOverrides();
@@ -655,7 +677,6 @@ namespace ErsatzTV.Infrastructure.Plex
var episode = new PlexEpisode
{
Key = response.Key,
EpisodeNumber = response.Index,
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version }
};
@@ -673,6 +694,7 @@ namespace ErsatzTV.Infrastructure.Plex
MetadataKind = MetadataKind.External,
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
EpisodeNumber = response.Index,
Plot = response.Summary,
Year = response.Year,
Tagline = response.Tagline,
+21 -15
View File
@@ -586,24 +586,30 @@ namespace ErsatzTV.Infrastructure.Search
private void UpdateEpisode(Episode episode)
{
Option<EpisodeMetadata> maybeMetadata = episode.EpisodeMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
EpisodeMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
if (string.IsNullOrWhiteSpace(metadata.Title))
{
new StringField(IdField, episode.Id.ToString(), Field.Store.YES),
new StringField(TypeField, EpisodeType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
_logger.LogWarning(
"Unable to index episode without title {Show} s{Season}e{Episode}",
metadata.Episode.Season?.Show?.ShowMetadata.Head().Title,
metadata.Episode.Season?.SeasonNumber,
metadata.EpisodeNumber);
continue;
}
var doc = new Document();
doc.Add(new StringField(IdField, episode.Id.ToString(), Field.Store.YES));
doc.Add(new StringField(TypeField, EpisodeType, Field.Store.NO));
doc.Add(new TextField(TitleField, metadata.Title, Field.Store.NO));
doc.Add(new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO));
doc.Add(new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO));
doc.Add(new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO));
doc.Add(new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO));
doc.Add(new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES));
AddLanguages(doc, episode.MediaVersions);
@@ -682,7 +688,7 @@ namespace ErsatzTV.Infrastructure.Search
metadata switch
{
EpisodeMetadata em =>
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.Episode.EpisodeNumber}"
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.EpisodeNumber}"
.ToLowerInvariant(),
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
};
+5
View File
@@ -13,6 +13,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=discardcorrupt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Emby/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=episodedetails/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fanart/@EntryIndexedValue">True</s:Boolean>
@@ -32,6 +33,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=maxrate/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=movflags/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mpaa/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mpegts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxdelay/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxpreload/@EntryIndexedValue">True</s:Boolean>
@@ -41,7 +43,10 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean>
<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/=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/=xmltv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
+5 -2
View File
@@ -27,8 +27,11 @@ namespace ErsatzTV.Controllers
.ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream(string channelNumber) =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber)).Map(
public Task<IActionResult> GetStream(
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode)).Map(
result =>
result.Match<IActionResult>(
process =>
+10 -8
View File
@@ -27,8 +27,10 @@ namespace ErsatzTV.Controllers
}
[HttpGet("iptv/channels.m3u")]
public Task<IActionResult> GetChannelPlaylist() =>
_mediator.Send(new GetChannelPlaylist(Request.Scheme, Request.Host.ToString()))
public Task<IActionResult> GetChannelPlaylist(
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetChannelPlaylist(Request.Scheme, Request.Host.ToString(), mode))
.Map<ChannelPlaylist, IActionResult>(Ok);
[HttpGet("iptv/xmltv.xml")]
@@ -54,14 +56,14 @@ namespace ErsatzTV.Controllers
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
public Task<IActionResult> GetHttpLiveStreamingVideo(string channelNumber) =>
_mediator.Send(new GetHlsPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
_mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber))
.Map(
result => result.Match<IActionResult>(
playlist =>
{
_logger.LogInformation("Starting hls stream for channel {ChannelNumber}", channelNumber);
return Content(playlist, "application/x-mpegurl");
},
playlist => Content(playlist, "application/x-mpegurl"),
error => BadRequest(error.Value)));
[HttpGet("iptv/logos/{fileName}")]
+2 -4
View File
@@ -24,10 +24,8 @@
<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)">
@foreach (StreamingMode streamingMode in Enum.GetValues<StreamingMode>())
{
<MudSelectItem Value="@streamingMode">@streamingMode</MudSelectItem>
}
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)">
+26 -9
View File
@@ -2,6 +2,8 @@
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Channels.Commands
@using ErsatzTV.Application.Channels.Queries
@using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Queries
@using System.Globalization
@@ -9,7 +11,10 @@
@inject IMediator _mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_channels">
<MudTable Hover="true"
@bind-RowsPerPage="@_rowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<ChannelViewModel>>>(ServerReload))"
@ref="_table">
<ToolBarContent>
<MudText Typo="Typo.h6">Channels</MudText>
</ToolBarContent>
@@ -45,7 +50,7 @@
</MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Language">@context.PreferredLanguageCode</MudTd>
<MudTd DataLabel="Mode">@(context.StreamingMode == StreamingMode.TransportStream ? "TS" : "HLS")</MudTd>
<MudTd DataLabel="Mode">@(context.StreamingMode == StreamingMode.TransportStream ? "MPEG-TS" : "HLS Direct")</MudTd>
<MudTd DataLabel="FFmpeg Profile">
@if (context.StreamingMode == StreamingMode.TransportStream)
{
@@ -77,13 +82,16 @@
</MudContainer>
@code {
private List<ChannelViewModel> _channels;
private MudTable<ChannelViewModel> _table;
private List<FFmpegProfileViewModel> _ffmpegProfiles;
private int _rowsPerPage;
protected override async Task OnParametersSetAsync()
{
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles());
await LoadChannelsAsync();
_rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
private async Task DeleteChannelAsync(ChannelViewModel channel)
@@ -96,17 +104,19 @@
if (!result.Cancelled)
{
await _mediator.Send(new DeleteChannel(channel.Id));
await LoadChannelsAsync();
await _table.ReloadServerData();
}
}
private async Task LoadChannelsAsync()
private async Task<TableData<ChannelViewModel>> ServerReload(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()));
List<ChannelViewModel> channels = await _mediator.Send(new GetAllChannels());
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number));
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
_channels = new List<ChannelViewModel>();
var processedChannels = new List<ChannelViewModel>();
foreach (ChannelViewModel channel in sorted)
{
Option<CultureInfo> maybeCultureInfo = allCultures.Find(
@@ -116,9 +126,16 @@
StringComparison.OrdinalIgnoreCase));
maybeCultureInfo.Match(
cultureInfo => _channels.Add(channel with { PreferredLanguageCode = cultureInfo.EnglishName }),
() => _channels.Add(channel));
cultureInfo => processedChannels.Add(channel with { PreferredLanguageCode = cultureInfo.EnglishName }),
() => processedChannels.Add(channel));
}
// TODO: properly page this data
return new TableData<ChannelViewModel>
{
TotalItems = channels.Count,
Items = processedChannels.Skip(state.Page * state.PageSize).Take(state.PageSize)
};
}
}
+9
View File
@@ -28,6 +28,14 @@
Disabled="@(_model.MediaCollectionPlaybackOrder != PlaybackOrder.Shuffle)"
For="@(() => _model.KeepMultiPartEpisodesTogether)"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTooltip Text="This is useful for multi-part crossover episodes">
<MudCheckBox Label="Treat Collections As Shows*"
@bind-Checked="@_model.TreatCollectionsAsShows"
Disabled="@(_model.KeepMultiPartEpisodesTogether == false)"
For="@(() => _model.TreatCollectionsAsShows)"/>
</MudTooltip>
</MudElement>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@@ -60,6 +68,7 @@
_model.Name = viewModel.Name;
_model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder;
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
},
() => _navigationManager.NavigateTo("404"));
}
+4 -2
View File
@@ -76,8 +76,10 @@ namespace ErsatzTV.Services
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
List<int> playoutIds = await dbContext.Playouts.Map(p => p.Id).ToListAsync(cancellationToken);
foreach (int playoutId in playoutIds)
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel)
.ToListAsync(cancellationToken);
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
{
await _workerChannel.WriteAsync(new BuildPlayout(playoutId), cancellationToken);
}
+3
View File
@@ -17,6 +17,7 @@ using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
@@ -24,6 +25,7 @@ using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Metadata.Nfo;
using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Formatters;
@@ -252,6 +254,7 @@ namespace ErsatzTV
});
services.AddScoped<IJellyfinSecretStore, JellyfinSecretStore>();
services.AddScoped<IEmbySecretStore, EmbySecretStore>();
services.AddScoped<IEpisodeNfoReader, EpisodeNfoReader>();
services.AddHostedService<EndpointValidatorService>();
services.AddHostedService<DatabaseMigratorService>();
@@ -9,11 +9,12 @@ namespace ErsatzTV.ViewModels
public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public UpdateProgramSchedule ToUpdate() =>
new(Id, Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether);
new(Id, Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows);
public CreateProgramSchedule ToCreate() =>
new(Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether);
new(Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows);
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ Channel numbers can be whole numbers or can contain one decimal, like `500` or `
### Streaming Mode
Two streaming modes are currently supported: `Transport Stream` and `HttpLiveStreaming`.
`Transport Stream` is considered stable and is recommended for most purposes.
`HttpLiveStreaming` is unstable and is not recommended for general use.
Two streaming modes are currently supported: `MPEG-TS` (Transport Stream) and `HLS Direct` (HTTP Live Streaming Direct).
`MPEG-TS` is considered stable and is recommended for most purposes.
`HLS Direct` is unstable and is not recommended for general use, but can avoid the need to transcode with some clients.
### FFmpeg Profile