Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bec9c5f07 | ||
|
|
0ef03d66f3 | ||
|
|
10c422a3eb | ||
|
|
6c867d0d51 | ||
|
|
ed0796ad58 | ||
|
|
49109ac121 | ||
|
|
3e3bbcf38e | ||
|
|
ce9ef72799 | ||
|
|
f8631a1f12 | ||
|
|
c70f153241 | ||
|
|
eee10dee22 | ||
|
|
9f575dbd94 |
+32
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2889
File diff suppressed because it is too large
Load Diff
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2892
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2892
File diff suppressed because it is too large
Load Diff
+17
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+2889
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2889
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,
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user