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