Compare commits

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

* wip

* implement watermark settings

* code cleanup

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

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

* update changelog
2021-06-07 07:41:59 -05:00
Jason Dove 3bc3faa7c4 artist schedule doc update [no docker] 2021-06-06 20:25:39 -05:00
Jason DoveandGitHub db24ba84f7 add artists directly to schedules (#248) 2021-06-06 20:12:17 -05:00
Jason DoveandGitHub 8346a02747 ignore unsupported plex guids (#246) 2021-06-05 15:53:38 -05:00
Jason Dove c3b33c184f fix changelog [no docker] 2021-06-05 13:39:29 -05:00
Jason Dove 6bec9c5f07 update docs for 0.0.43-prealpha [no docker] 2021-06-05 13:36:08 -05:00
Jason DoveandGitHub 0ef03d66f3 improve hls direct compatibility with channels dvr (#245)
* rename HttpLiveStreaming to HttpLiveStreamingDirect

* improve hls direct compatibility with channels dvr

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

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

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

* move episode number from episode to episode metadata

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

* fix episode scanning

* local multi-part episode fixes

* code cleanup
2021-06-04 06:00:35 -05:00
Jason DoveandGitHub ce9ef72799 support (part #) names for multi-episode grouping (#238) 2021-06-01 07:15:17 -05:00
116 changed files with 22625 additions and 502 deletions
+45 -1
View File
@@ -5,6 +5,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.45-prealpha] - 2021-06-12
### Added
- Add experimental `HLS Hybrid` channel mode
- Media items are transcoded using the channel's ffmpeg profile and served using HLS
- Add optional channel watermark
### Changed
- Remove framerate normalization; it caused more problems than it solved
- Include non-US (and unknown) content ratings in XMLTV
### Fixed
- Fix serving channels.m3u with missing content ratings
- Fix percent progress indicator for Jellyfin and Emby show library scans
## [0.0.44-prealpha] - 2021-06-09
### Added
- Add artists directly to schedules
- Include MPAA and VCHIP content ratings in XMLTV guide data
- Quickly skip missing files during Plex library scan
### Fixed
- Ignore unsupported plex guids (this prevented some libraries from scanning correctly)
- Ignore unsupported STRM files from Jellyfin
## [0.0.43-prealpha] - 2021-06-05
### Added
- Support `(Part #)` name suffixes for multi-part episode grouping
- 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 +433,10 @@ 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.45-prealpha...HEAD
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
[0.0.44-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...v0.0.43-prealpha
[0.0.42-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
[0.0.41-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
[0.0.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());
}
}
@@ -9,5 +9,13 @@ namespace ErsatzTV.Application.Channels
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode);
StreamingMode StreamingMode,
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds);
}
@@ -12,5 +12,13 @@ namespace ErsatzTV.Application.Channels.Commands
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
StreamingMode StreamingMode,
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds) : IRequest<Either<BaseError, ChannelViewModel>>;
}
@@ -57,7 +57,7 @@ namespace ErsatzTV.Application.Channels.Commands
});
}
return new Channel(Guid.NewGuid())
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
@@ -66,6 +66,23 @@ namespace ErsatzTV.Application.Channels.Commands
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
};
if (request.WatermarkMode != ChannelWatermarkMode.None)
{
channel.Watermark = new ChannelWatermark
{
Mode = request.WatermarkMode,
Location = request.WatermarkLocation,
Size = request.WatermarkSize,
WidthPercent = request.WatermarkWidth,
HorizontalMarginPercent = request.WatermarkHorizontalMargin,
VerticalMarginPercent = request.WatermarkVerticalMargin,
FrequencyMinutes = request.WatermarkFrequencyMinutes,
DurationSeconds = request.WatermarkDurationSeconds
};
}
return channel;
});
private Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
@@ -13,5 +13,13 @@ namespace ErsatzTV.Application.Channels.Commands
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
StreamingMode StreamingMode,
ChannelWatermarkMode WatermarkMode,
ChannelWatermarkLocation WatermarkLocation,
ChannelWatermarkSize WatermarkSize,
int WatermarkWidth,
int WatermarkHorizontalMargin,
int WatermarkVerticalMargin,
int WatermarkFrequencyMinutes,
int WatermarkDurationSeconds) : IRequest<Either<BaseError, ChannelViewModel>>;
}
@@ -61,6 +61,39 @@ namespace ErsatzTV.Application.Channels.Commands
});
}
if (update.WatermarkMode == ChannelWatermarkMode.None)
{
await _channelRepository.RemoveWatermark(c);
}
else
{
if (c.Watermark != null)
{
c.Watermark.Mode = update.WatermarkMode;
c.Watermark.Location = update.WatermarkLocation;
c.Watermark.Size = update.WatermarkSize;
c.Watermark.WidthPercent = update.WatermarkWidth;
c.Watermark.HorizontalMarginPercent = update.WatermarkHorizontalMargin;
c.Watermark.VerticalMarginPercent = update.WatermarkVerticalMargin;
c.Watermark.FrequencyMinutes = update.WatermarkFrequencyMinutes;
c.Watermark.DurationSeconds = update.WatermarkDurationSeconds;
}
else
{
c.Watermark = new ChannelWatermark
{
Mode = update.WatermarkMode,
Location = update.WatermarkLocation,
Size = update.WatermarkSize,
WidthPercent = update.WatermarkWidth,
HorizontalMarginPercent = update.WatermarkHorizontalMargin,
VerticalMarginPercent = update.WatermarkVerticalMargin,
FrequencyMinutes = update.WatermarkFrequencyMinutes,
DurationSeconds = update.WatermarkDurationSeconds
};
}
}
c.StreamingMode = update.StreamingMode;
await _channelRepository.Update(c);
return ProjectToViewModel(c);
+9 -1
View File
@@ -14,7 +14,15 @@ namespace ErsatzTV.Application.Channels
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode);
channel.StreamingMode,
channel.Watermark?.Mode ?? ChannelWatermarkMode.None,
channel.Watermark?.Location ?? ChannelWatermarkLocation.BottomRight,
channel.Watermark?.Size ?? ChannelWatermarkSize.Scaled,
channel.Watermark?.WidthPercent ?? 15,
channel.Watermark?.HorizontalMarginPercent ?? 5,
channel.Watermark?.VerticalMarginPercent ?? 5,
channel.Watermark?.FrequencyMinutes ?? 15,
channel.Watermark?.DurationSeconds ?? 15);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
@@ -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;
}
}
}
@@ -21,6 +21,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -53,8 +53,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
NormalizeLoudness = request.NormalizeLoudness,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio,
FrameRate = request.FrameRate
NormalizeAudio = request.NormalizeAudio
});
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -22,6 +22,5 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -48,7 +48,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio;
p.FrameRate = update.FrameRate;
await _ffmpegProfileRepository.Update(p);
return ProjectToViewModel(p);
}
@@ -20,6 +20,5 @@ namespace ErsatzTV.Application.FFmpegProfiles
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio,
string FrameRate);
bool NormalizeAudio);
}
@@ -23,8 +23,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.NormalizeLoudness,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeAudio,
profile.FrameRate);
profile.NormalizeAudio);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
+20 -5
View File
@@ -50,17 +50,14 @@ namespace ErsatzTV.Application.MediaCards
episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.Season.SeasonNumber,
episodeMetadata.Episode.EpisodeNumber,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, () => 0),
episodeMetadata.Title,
episodeMetadata.SortTitle,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
isSearchResult
? GetPoster(
episodeMetadata.Episode.Season.SeasonMetadata.Head(),
maybeJellyfin,
maybeEmby)
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
@@ -146,6 +143,24 @@ namespace ErsatzTV.Application.MediaCards
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";
private static string GetEpisodePoster(
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
Option<SeasonMetadata> maybeSeasonMetadata = episodeMetadata.Episode.Season.SeasonMetadata.HeadOrNone();
return maybeSeasonMetadata.Match(
seasonMetadata => GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
() =>
{
Option<ShowMetadata> maybeShowMetadata =
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone();
return maybeShowMetadata.Match(
showMetadata => GetPoster(showMetadata, maybeJellyfin, maybeEmby),
() => string.Empty);
});
}
private static string GetPoster(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
+5 -2
View File
@@ -7,12 +7,15 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId);
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
internal static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
public static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
internal static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
new(season.Id, $"{ShowTitle(season)} ({SeasonDescription(season)})");
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???"));
private static string ShowTitle(Season season) =>
season.Show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNone("???");
+11 -3
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts
@@ -31,9 +32,16 @@ namespace ErsatzTV.Application.Playouts
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList();
var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList();
if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
{
return "[unknown episode]";
}
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
var titlesString = $"{string.Join('/', episodeTitles)}";
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
@@ -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;
@@ -5,5 +5,5 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetHlsPlaylistByChannelNumber
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, string>>;
(string Scheme, string Host, string ChannelNumber, string Mode) : IRequest<Either<BaseError, string>>;
}
@@ -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(
@@ -35,17 +39,24 @@ namespace ErsatzTV.Application.Streaming.Queries
GetHlsPlaylistByChannelNumber request,
Channel channel)
{
string mode = string.IsNullOrWhiteSpace(request.Mode)
? string.Empty
: $"&mode={request.Mode}";
DateTimeOffset now = DateTimeOffset.Now;
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
return maybePlayoutItem.Match<Either<BaseError, string>>(
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:10
#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}
";
},
() =>
@@ -59,5 +70,36 @@ namespace ErsatzTV.Application.Streaming.Queries
private async Task<Validation<BaseError, Channel>> ChannelMustExist(GetHlsPlaylistByChannelNumber request) =>
(await _channelRepository.GetByNumber(request.ChannelNumber))
.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.");
private long GetIndexForChannel(Channel channel, PlayoutItem playoutItem)
{
long ticks = playoutItem.Start.Ticks;
var key = new ChannelIndexKey(channel.Id);
long index;
if (_memoryCache.TryGetValue(key, out ChannelIndexRecord channelRecord))
{
if (channelRecord.StartTicks == ticks)
{
index = channelRecord.Index;
}
else
{
index = channelRecord.Index + 1;
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
}
}
else
{
index = 1;
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
}
return index;
}
private record ChannelIndexKey(int ChannelId);
private record ChannelIndexRecord(long StartTicks, long Index);
}
}
@@ -2,7 +2,7 @@
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(string channelNumber) : base(channelNumber)
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
{
}
}
+10 -6
View File
@@ -57,14 +57,18 @@ namespace ErsatzTV.Application.Television
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby))
.IfNone(string.Empty));
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
new(
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode)
{
Option<EpisodeMetadata> maybeMetadata = episode.EpisodeMetadata.HeadOrNone();
return new TelevisionEpisodeViewModel(
episode.Season.ShowId,
episode.SeasonId,
episode.EpisodeNumber,
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
maybeMetadata.Map(em => em.EpisodeNumber).IfNone(0),
maybeMetadata.Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
maybeMetadata.Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
maybeMetadata.Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
}
private static string GetPoster(
Metadata metadata,
@@ -40,7 +40,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -76,7 +76,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -112,7 +112,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -151,7 +151,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -317,7 +317,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
version,
new MediaStream(),
@@ -426,7 +426,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
@@ -467,36 +467,6 @@ namespace ErsatzTV.Core.Tests.FFmpeg
actual.VideoCodec.Should().Be("copy");
}
[Test]
public void
Should_SetCorrectVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_And_Framerate_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264",
FrameRate = "24"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "libx264" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("libx264");
}
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
@@ -718,7 +688,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) };
+2
View File
@@ -14,6 +14,8 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
+37
View File
@@ -0,0 +1,37 @@
namespace ErsatzTV.Core.Domain
{
public class ChannelWatermark
{
public int Id { get; set; }
public Channel Channel { get; set; }
public ChannelWatermarkLocation Location { get; set; }
public ChannelWatermarkSize Size { get; set; }
public ChannelWatermarkMode Mode { get; set; }
public int WidthPercent { get; set; }
public int HorizontalMarginPercent { get; set; }
public int VerticalMarginPercent { get; set; }
public int FrequencyMinutes { get; set; }
public int DurationSeconds { get; set; }
}
public enum ChannelWatermarkLocation
{
BottomRight = 0,
BottomLeft = 1,
TopRight = 2,
TopLeft = 3
}
public enum ChannelWatermarkSize
{
Scaled = 0,
ActualSize = 1
}
public enum ChannelWatermarkMode
{
None = 0,
Permanent = 1,
Intermittent = 2
}
}
+1
View File
@@ -14,6 +14,7 @@
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
}
-2
View File
@@ -13,7 +13,6 @@
public bool NormalizeVideo { get; set; }
public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; }
public string FrameRate { get; set; }
public string AudioCodec { get; set; }
public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; }
@@ -40,7 +39,6 @@
AudioChannels = 2,
AudioSampleRate = 48,
NormalizeVideo = true,
FrameRate = "24",
NormalizeAudio = true
};
}
@@ -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
}
}
+2 -1
View File
@@ -3,6 +3,7 @@
public enum StreamingMode
{
TransportStream = 1,
HttpLiveStreaming = 2
HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3
}
}
@@ -115,9 +115,10 @@ namespace ErsatzTV.Core.Emby
List<EmbyItemEtag> existingShows,
List<EmbyShow> shows)
{
foreach (EmbyShow incoming in shows.OrderBy(s => s.ShowMetadata.Head().Title))
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (EmbyShow incoming in sortedShows)
{
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
decimal percentCompletion = (decimal) sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
@@ -335,7 +336,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 +371,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))
{
@@ -13,12 +13,13 @@ namespace ErsatzTV.Core.FFmpeg
{
private Option<TimeSpan> _audioDuration = None;
private bool _deinterlace;
private Option<string> _frameRate = None;
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
private string _inputCodec;
private bool _normalizeLoudness;
private Option<IDisplaySize> _padToSize = None;
private IDisplaySize _resolution;
private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -62,9 +63,10 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithFrameRate(Option<string> frameRate)
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
{
_frameRate = frameRate;
_watermark = watermark;
_resolution = resolution;
return this;
}
@@ -86,6 +88,8 @@ namespace ErsatzTV.Core.FFmpeg
var audioFilterQueue = new List<string>();
var videoFilterQueue = new List<string>();
string watermarkScale = string.Empty;
string watermarkOverlay = string.Empty;
if (_normalizeLoudness)
{
@@ -118,8 +122,6 @@ namespace ErsatzTV.Core.FFmpeg
}
}
_frameRate.IfSome(frameRate => videoFilterQueue.Add($"fps=fps={frameRate}"));
_scaleToSize.IfSome(
size =>
{
@@ -137,7 +139,10 @@ namespace ErsatzTV.Core.FFmpeg
}
});
if (_scaleToSize.IsSome || _padToSize.IsSome)
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
bool usesSoftwareFilters = scaleOrPad || _watermark.IsSome;
if (usesSoftwareFilters)
{
if (acceleration != HardwareAccelerationKind.None && (isHardwareDecode || usesHardwareFilters))
{
@@ -150,12 +155,42 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add(format);
}
videoFilterQueue.Add("setsar=1");
if (scaleOrPad)
{
videoFilterQueue.Add("setsar=1");
}
foreach (ChannelWatermark watermark in _watermark)
{
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
? $":enable='lt(mod(mod(time(0),60*60),{watermark.FrequencyMinutes}*60),{watermark.DurationSeconds})'"
: string.Empty;
double horizontalMargin = Math.Round(watermark.HorizontalMarginPercent / 100.0 * _resolution.Width);
double verticalMargin = Math.Round(watermark.VerticalMarginPercent / 100.0 * _resolution.Height);
string position = watermark.Location switch
{
ChannelWatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
};
if (watermark.Size == ChannelWatermarkSize.Scaled)
{
double width = Math.Round(watermark.WidthPercent / 100.0 * _resolution.Width);
watermarkScale = $"scale={width}:-1";
}
watermarkOverlay = $"overlay={position}{enable}";
}
}
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
if ((_scaleToSize.IsSome || _padToSize.IsSome) && acceleration != HardwareAccelerationKind.None)
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
string.IsNullOrWhiteSpace(watermarkOverlay))
{
string upload = acceleration switch
{
@@ -182,7 +217,26 @@ namespace ErsatzTV.Core.FFmpeg
}
complexFilter.Append($"[{videoLabel}]");
complexFilter.Append(string.Join(",", videoFilterQueue));
var filters = string.Join(",", videoFilterQueue);
complexFilter.Append(filters);
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
{
complexFilter.Append("[vt]");
var watermarkLabel = "[1:v]";
if (!string.IsNullOrWhiteSpace(watermarkScale))
{
complexFilter.Append($";{watermarkLabel}{watermarkScale}[wms]");
watermarkLabel = "[wms]";
}
complexFilter.Append($";[vt]{watermarkLabel}{watermarkOverlay}");
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
{
complexFilter.Append(",hwupload");
}
}
videoLabel = "[v]";
complexFilter.Append(videoLabel);
}
@@ -26,7 +26,6 @@ namespace ErsatzTV.Core.FFmpeg
public Option<TimeSpan> AudioDuration { get; set; }
public string AudioCodec { get; set; }
public bool Deinterlace { get; set; }
public Option<string> FrameRate { get; set; }
public Option<int> VideoTrackTimeScale { get; set; }
public bool NormalizeLoudness { get; set; }
}
@@ -64,11 +64,12 @@ namespace ErsatzTV.Core.FFmpeg
switch (streamingMode)
{
case StreamingMode.HttpLiveStreaming:
case StreamingMode.HttpLiveStreamingDirect:
result.AudioCodec = "copy";
result.VideoCodec = "copy";
result.Deinterlace = false;
break;
case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@@ -91,15 +92,11 @@ namespace ErsatzTV.Core.FFmpeg
if (ffmpegProfile.NormalizeVideo)
{
result.FrameRate = string.IsNullOrWhiteSpace(ffmpegProfile.FrameRate)
? None
: Some(ffmpegProfile.FrameRate);
result.VideoTrackTimeScale = 90000;
}
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream) || result.FrameRate.IsSome)
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
{
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
+23 -6
View File
@@ -152,6 +152,29 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithWatermark(
Option<ChannelWatermark> watermark,
Option<string> maybePath,
IDisplaySize resolution,
bool isAnimated)
{
foreach (string path in maybePath)
{
if (isAnimated)
{
_arguments.Add("-ignore_loop");
_arguments.Add("0");
}
_arguments.Add("-i");
_arguments.Add(path);
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(watermark, resolution);
}
return this;
}
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec)
{
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
@@ -332,12 +355,6 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFrameRate(Option<string> frameRate)
{
_complexFilterBuilder = _complexFilterBuilder.WithFrameRate(frameRate);
return this;
}
public FFmpegProcessBuilder WithVideoTrackTimeScale(Option<int> videoTrackTimeScale)
{
videoTrackTimeScale.IfSome(
+18 -2
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
namespace ErsatzTV.Core.FFmpeg
@@ -11,14 +12,17 @@ namespace ErsatzTV.Core.FFmpeg
public class FFmpegProcessService
{
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService(
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector)
IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache)
{
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache;
}
public async Task<Process> ForPlayoutItem(
@@ -42,6 +46,18 @@ namespace ErsatzTV.Core.FFmpeg
start,
now);
Option<string> maybeWatermarkPath = channel.Artwork
.Filter(_ => channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
bool isAnimated = await maybeWatermarkPath.Match(
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false));
Option<ChannelWatermark> maybeWatermark = channel.Watermark;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
@@ -50,7 +66,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
.WithFrameRate(playbackSettings.FrameRate)
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
+1 -1
View File
@@ -28,7 +28,7 @@ namespace ErsatzTV.Core.FFmpeg
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming &&
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
{
_logger.LogDebug(
+2 -1
View File
@@ -22,7 +22,8 @@ namespace ErsatzTV.Core.Hdhr
public string URL => _channel.StreamingMode switch
{
StreamingMode.HttpLiveStreaming => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
StreamingMode.HttpLiveStreamingDirect or StreamingMode.HttpLiveStreamingHybrid =>
$"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
};
}
@@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Images
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
Task<bool> IsAnimated(string fileName);
}
}
@@ -1,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();
}
}
@@ -12,8 +12,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Channel>> GetByNumber(string number);
Task<List<Channel>> GetAll();
Task<List<Channel>> GetAllForGuide();
Task Update(Channel channel);
Task<bool> Update(Channel channel);
Task Delete(int channelId);
Task<int> CountPlayouts(int channelId);
Task<Unit> RemoveWatermark(Channel channel);
}
}
@@ -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);
}
+57 -6
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
@@ -8,6 +9,7 @@ using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Serilog;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Iptv
@@ -94,7 +96,7 @@ namespace ErsatzTV.Core.Iptv
string title = GetTitle(startItem);
string subtitle = GetSubtitle(startItem);
string description = GetDescription(startItem);
string contentRating = string.Empty;
Option<ContentRating> contentRating = GetContentRating(startItem);
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
@@ -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,16 @@ namespace ErsatzTV.Core.Iptv
}
}
if (!string.IsNullOrWhiteSpace(contentRating))
foreach (ContentRating rating in contentRating)
{
xml.WriteStartElement("rating");
xml.WriteAttributeString("system", "MPAA");
foreach (string system in rating.System)
{
xml.WriteAttributeString("system", system);
}
xml.WriteStartElement("value");
xml.WriteString(contentRating);
xml.WriteString(rating.Value);
xml.WriteEndElement(); // value
xml.WriteEndElement(); // rating
}
@@ -321,5 +328,49 @@ namespace ErsatzTV.Core.Iptv
_ => string.Empty
};
}
private static Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
{
try
{
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata
.HeadOrNone()
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
Episode e => e.Season.Show.ShowMetadata
.HeadOrNone()
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
_ => None
};
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
return None;
}
}
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
{
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
return maybeFirst.Map(
first =>
{
string[] split = first.Split(':');
if (split.Length == 2)
{
return split[0].ToLowerInvariant() == "us"
? new ContentRating(system, split[1].ToUpperInvariant())
: new ContentRating(None, split[1].ToUpperInvariant());
}
return string.IsNullOrWhiteSpace(first)
? Option<ContentRating>.None
: new ContentRating(None, first);
}).Flatten();
}
private record ContentRating(Option<string> System, string Value);
}
}
+2 -1
View File
@@ -42,7 +42,8 @@ namespace ErsatzTV.Core.Iptv
string format = channel.StreamingMode switch
{
StreamingMode.HttpLiveStreaming => "m3u8",
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
_ => "ts"
};
@@ -115,9 +115,10 @@ namespace ErsatzTV.Core.Jellyfin
List<JellyfinItemEtag> existingShows,
List<JellyfinShow> shows)
{
foreach (JellyfinShow incoming in shows.OrderBy(s => s.ShowMetadata.Head().Title))
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (JellyfinShow incoming in sortedShows)
{
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
decimal percentCompletion = (decimal) sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
@@ -336,7 +337,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 +372,7 @@ namespace ErsatzTV.Core.Jellyfin
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeNumber);
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming))
{
@@ -26,13 +26,28 @@ namespace ErsatzTV.Core.Metadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
}
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
public List<EpisodeMetadata> GetFallbackMetadata(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
var metadata = new EpisodeMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow };
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
var baseMetadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
DateAdded = DateTime.UtcNow,
EpisodeNumber = 0,
Actors = new List<Actor>(),
Artwork = new List<Artwork>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Guids = new List<MetadataGuid>(),
Studios = new List<Studio>(),
Tags = new List<Tag>(),
Writers = new List<Writer>()
};
return fileName != null
? GetEpisodeMetadata(fileName, baseMetadata)
: new List<EpisodeMetadata> { baseMetadata };
}
public MovieMetadata GetFallbackMetadata(Movie movie)
@@ -87,18 +102,47 @@ namespace ErsatzTV.Core.Metadata
return title;
}
private Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
private static List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
{
var result = new List<EpisodeMetadata>();
try
{
const string PATTERN = @"^(.*?)[.\s-]+[sS](\d+)[eE](\d+).*\.\w+$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
const string PATTERN = @"[sS]\d+[eE]([e\-\d{1,2}]+)";
MatchCollection matches = Regex.Matches(fileName, PATTERN);
if (matches.Count > 0)
{
metadata.Title = match.Groups[1].Value;
metadata.DateUpdated = DateTime.UtcNow;
metadata.Actors = new List<Actor>();
return Tuple(metadata, int.Parse(match.Groups[3].Value));
foreach (Match match in matches)
{
string[] split = match.Groups[1].Value.Replace('e', '-').Split('-');
foreach (string ep in split)
{
if (!int.TryParse(ep, out int episodeNumber))
{
continue;
}
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Fallback,
EpisodeNumber = episodeNumber,
DateAdded = baseMetadata.DateAdded,
DateUpdated = baseMetadata.DateAdded,
Actors = new List<Actor>(),
Artwork = new List<Artwork>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Guids = new List<MetadataGuid>(),
Studios = new List<Studio>(),
Tags = new List<Tag>(),
Writers = new List<Writer>()
};
result.Add(metadata);
}
}
return result;
}
}
catch (Exception)
@@ -106,7 +150,7 @@ namespace ErsatzTV.Core.Metadata
// ignored
}
return Tuple(metadata, 0);
return result;
}
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
+145 -116
View File
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata.Nfo;
using LanguageExt;
@@ -17,11 +18,11 @@ namespace ErsatzTV.Core.Metadata
public class LocalMetadataProvider : ILocalMetadataProvider
{
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository;
private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
@@ -39,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
IEpisodeNfoReader episodeNfoReader,
ILogger<LocalMetadataProvider> logger)
{
_metadataRepository = metadataRepository;
@@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_episodeNfoReader = episodeNfoReader;
_logger = logger;
}
@@ -110,10 +113,7 @@ namespace ErsatzTV.Core.Metadata
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
LoadEpisodeMetadata(episode, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(episode, metadata),
() => Task.FromResult(false)));
LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
@@ -174,118 +174,138 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
{
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
if (episode.EpisodeNumber != episodeNumber)
var updated = false;
episode.EpisodeMetadata ??= new List<EpisodeMetadata>();
var toUpdate = episode.EpisodeMetadata
.Where(em => episodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
.ToList();
var toRemove = episode.EpisodeMetadata.Except(toUpdate).ToList();
var toAdd = episodeMetadata
.Where(em => episode.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
.ToList();
foreach (EpisodeMetadata metadata in toRemove)
{
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
await _televisionRepository.RemoveMetadata(episode, metadata);
updated = true;
}
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
foreach (EpisodeMetadata metadata in toAdd)
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.EpisodeId = episode.Id;
metadata.Episode = episode;
episode.EpisodeMetadata.Add(metadata);
if (existing.DateAdded == DateTime.MinValue)
updated = await _metadataRepository.Add(metadata) || updated;
}
foreach (EpisodeMetadata metadata in toUpdate)
{
Option<EpisodeMetadata> maybeExisting =
episode.EpisodeMetadata.Find(em => em.EpisodeNumber == metadata.EpisodeNumber);
updated = await maybeExisting.Match(
async existing =>
{
existing.DateAdded = metadata.DateAdded;
}
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
if (existing.DateAdded == DateTime.MinValue)
{
updated = true;
existing.DateAdded = metadata.DateAdded;
}
}
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor) || updated;
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
updated = true;
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
}
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
updated = true;
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
{
updated = true;
}
}
}
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
updated = true;
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
updated = true;
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
{
updated = true;
}
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
updated = true;
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.EpisodeId = episode.Id;
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Add(metadata);
});
return await _metadataRepository.Update(existing) || updated;
},
() => Task.FromResult(updated)) || updated;
}
return true;
return updated;
}
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
@@ -665,36 +685,45 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
private async Task<List<EpisodeMetadata>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>(
nfo =>
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
List<TvShowEpisodeNfo> nfos = await _episodeNfoReader.Read(fileStream);
var result = new List<EpisodeMetadata>();
foreach (TvShowEpisodeNfo nfo in nfos)
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList(),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList()
};
return Tuple(metadata, nfo.Episode);
},
None);
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(nfo.Title),
EpisodeNumber = nfo.Episode,
Year = GetYear(0, nfo.Aired),
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList(),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Artwork = new List<Artwork>()
};
result.Add(metadata);
}
return result;
}
catch (Exception ex)
{
@@ -883,7 +912,7 @@ namespace ErsatzTV.Core.Metadata
return updated;
}
private List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
private static List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
{
var result = new List<Actor>();
@@ -0,0 +1,201 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
namespace ErsatzTV.Core.Metadata.Nfo
{
public class EpisodeNfoReader : IEpisodeNfoReader
{
public async Task<List<TvShowEpisodeNfo>> Read(Stream input)
{
var result = new List<TvShowEpisodeNfo>();
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
using var reader = XmlReader.Create(input, settings);
TvShowEpisodeNfo nfo = null;
while (await reader.ReadAsync())
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
switch (reader.Name.ToLowerInvariant())
{
case "episodedetails":
nfo = new TvShowEpisodeNfo
{
UniqueIds = new List<UniqueIdNfo>(),
Actors = new List<ActorNfo>(),
Writers = new List<string>(),
Directors = new List<string>()
};
break;
case "title":
await ReadTitle(reader, nfo);
break;
case "showtitle":
await ReadShowTitle(reader, nfo);
break;
case "episode":
await ReadEpisode(reader, nfo);
break;
case "season":
await ReadSeason(reader, nfo);
break;
case "uniqueid":
await ReadUniqueId(reader, nfo);
break;
case "mpaa":
await ReadContentRating(reader, nfo);
break;
case "aired":
// TODO: parse the date here
await ReadAired(reader, nfo);
break;
case "plot":
await ReadPlot(reader, nfo);
break;
case "actor":
ReadActor(reader, nfo);
break;
case "credits":
await ReadWriter(reader, nfo);
break;
case "director":
await ReadDirector(reader, nfo);
break;
}
break;
case XmlNodeType.EndElement:
switch (reader.Name.ToLowerInvariant())
{
case "episodedetails":
if (nfo != null)
{
result.Add(nfo);
}
break;
}
break;
}
}
return result;
}
private static async Task ReadTitle(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Title = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadShowTitle(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.ShowTitle = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadEpisode(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int episode);
nfo.Episode = episode;
}
}
private static async Task ReadSeason(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int season);
nfo.Season = season;
}
}
private static async Task ReadUniqueId(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
var uniqueId = new UniqueIdNfo();
reader.MoveToAttribute("default");
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
reader.MoveToAttribute("type");
uniqueId.Type = reader.Value;
reader.MoveToElement();
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
nfo.UniqueIds.Add(uniqueId);
}
}
private static async Task ReadContentRating(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.ContentRating = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadAired(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Aired = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadPlot(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Plot = await reader.ReadElementContentAsStringAsync();
}
}
private static void ReadActor(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
var actor = new ActorNfo();
var element = (XElement) XNode.ReadFrom(reader);
XElement name = element.Element("name");
if (name != null)
{
actor.Name = name.Value;
}
XElement role = element.Element("role");
if (role != null)
{
actor.Role = role.Value;
}
XElement thumb = element.Element("thumb");
if (thumb != null)
{
actor.Thumb = thumb.Value;
}
nfo.Actors.Add(actor);
}
}
private static async Task ReadWriter(XmlReader reader, TvShowEpisodeNfo nfo) =>
nfo?.Writers.Add(await reader.ReadElementContentAsStringAsync());
private static async Task ReadDirector(XmlReader reader, TvShowEpisodeNfo nfo) =>
nfo?.Directors.Add(await reader.ReadElementContentAsStringAsync());
}
}
@@ -24,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
@@ -49,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_libraryRepository = libraryRepository;
@@ -169,6 +171,7 @@ namespace ErsatzTV.Core.Metadata
{
Either<BaseError, Season> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
.BindT(EnsureMetadataExists)
.BindT(season => UpdatePoster(season, seasonFolder));
await maybeSeason.Match(
@@ -270,8 +273,28 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, Episode>> UpdateMetadata(
Episode episode)
private async Task<Either<BaseError, Season>> EnsureMetadataExists(Season season)
{
season.SeasonMetadata ??= new List<SeasonMetadata>();
if (!season.SeasonMetadata.Any())
{
var metadata = new SeasonMetadata
{
SeasonId = season.Id,
Season = season,
DateAdded = DateTime.UtcNow,
Guids = new List<MetadataGuid>()
};
season.SeasonMetadata.Add(metadata);
await _metadataRepository.Add(metadata);
}
return season;
}
private async Task<Either<BaseError, Episode>> UpdateMetadata(Episode episode)
{
try
{
@@ -360,8 +383,10 @@ namespace ErsatzTV.Core.Metadata
await LocateThumbnail(episode).IfSomeAsync(
async posterFile =>
{
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
}
});
return episode;
+34 -3
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
@@ -16,10 +17,13 @@ namespace ErsatzTV.Core.Plex
{
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@@ -31,6 +35,9 @@ namespace ErsatzTV.Core.Plex
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -40,6 +47,9 @@ namespace ErsatzTV.Core.Plex
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -48,6 +58,9 @@ namespace ErsatzTV.Core.Plex
PlexServerAuthToken token,
PlexLibrary library)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,
@@ -56,9 +69,27 @@ namespace ErsatzTV.Core.Plex
await entries.Match(
async movieEntries =>
{
foreach (PlexMovie incoming in movieEntries)
var validMovies = new List<PlexMovie>();
foreach (PlexMovie movie in movieEntries.OrderBy(m => m.MovieMetadata.Head().Title))
{
decimal percentCompletion = (decimal) movieEntries.IndexOf(incoming) / movieEntries.Count;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping plex movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
}
foreach (PlexMovie incoming in validMovies)
{
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
// TODO: figure out how to rebuild playlists
@@ -92,7 +123,7 @@ namespace ErsatzTV.Core.Plex
});
}
var movieKeys = movieEntries.Map(s => s.Key).ToList();
var movieKeys = validMovies.Map(s => s.Key).ToList();
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys);
await _searchIndex.RemoveItems(ids);
@@ -31,7 +31,13 @@ namespace ErsatzTV.Core.Plex
{
List<PlexPathReplacement> replacements =
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
Option<PlexPathReplacement> maybeReplacement = replacements
return GetReplacementPlexPath(replacements, path);
}
public string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true)
{
Option<PlexPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault(
r =>
{
@@ -39,6 +45,7 @@ namespace ErsatzTV.Core.Plex
string prefix = r.PlexPath.EndsWith(separatorChar) ? r.PlexPath : r.PlexPath + separatorChar;
return path.StartsWith(prefix);
});
return maybeReplacement.Match(
replacement =>
{
@@ -52,11 +59,15 @@ namespace ErsatzTV.Core.Plex
finalPath = finalPath.Replace(@"/", @"\");
}
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
if (log)
{
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
}
return finalPath;
},
() => path);
@@ -3,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))
{
+13 -1
View File
@@ -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))
};
@@ -21,6 +21,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(c => c.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.Watermark)
.WithOne(w => w.Channel)
.HasForeignKey<Channel>(c => c.WatermarkId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ChannelWatermarkConfiguration : IEntityTypeConfiguration<ChannelWatermark>
{
public void Configure(EntityTypeBuilder<ChannelWatermark> builder) => builder.ToTable("ChannelWatermark");
}
}
@@ -146,5 +146,28 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection.ExecuteAsync(
"INSERT INTO Mood (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { mood.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<MusicVideo>> GetArtistItems(int artistId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.Include(mv => mv.MediaVersions)
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(mv => mv.ArtistId == artistId)
.ToListAsync();
}
public async Task<List<Artist>> GetAllArtists()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Artists
.AsNoTracking()
.Include(a => a.ArtistMetadata)
.ThenInclude(am => am.Artwork)
.ToListAsync();
}
}
}
@@ -14,44 +14,59 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class ChannelRepository : IChannelRepository
{
private readonly IDbConnection _dbConnection;
private readonly TvContext _dbContext;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public ChannelRepository(TvContext dbContext, IDbConnection dbConnection)
public ChannelRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContext = dbContext;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<Channel> Add(Channel channel)
{
await _dbContext.Channels.AddAsync(channel);
await _dbContext.SaveChangesAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.Channels.AddAsync(channel);
await dbContext.SaveChangesAsync();
return channel;
}
public Task<Option<Channel>> Get(int id) =>
_dbContext.Channels
public async Task<Option<Channel>> Get(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
}
public Task<Option<Channel>> GetByNumber(string number) =>
_dbContext.Channels
public async Task<Option<Channel>> GetByNumber(string number)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.FFmpegProfile)
.ThenInclude(p => p.Resolution)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.OrderBy(c => c.Number)
.SingleOrDefaultAsync(c => c.Number == number)
.Map(Optional);
}
public Task<List<Channel>> GetAll() =>
_dbContext.Channels
public async Task<List<Channel>> GetAll()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.ToListAsync();
}
public Task<List<Channel>> GetAllForGuide() =>
_dbContext.Channels
public async Task<List<Channel>> GetAllForGuide()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Playouts)
.ThenInclude(p => p.Items)
@@ -80,23 +95,57 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ToListAsync();
}
public Task Update(Channel channel)
public async Task<bool> Update(Channel channel)
{
_dbContext.Channels.Update(channel);
return _dbContext.SaveChangesAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
dbContext.Entry(channel).State = EntityState.Modified;
if (channel.Watermark != null)
{
dbContext.Entry(channel.Watermark).State =
channel.WatermarkId == null ? EntityState.Added : EntityState.Modified;
}
foreach (Artwork artwork in Optional(channel.Artwork).Flatten())
{
dbContext.Entry(artwork).State = artwork.Id > 0 ? EntityState.Modified : EntityState.Added;
}
bool result = await dbContext.SaveChangesAsync() > 0;
return result;
}
public async Task Delete(int channelId)
{
Channel channel = await _dbContext.Channels.FindAsync(channelId);
_dbContext.Channels.Remove(channel);
await _dbContext.SaveChangesAsync();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Channel channel = await dbContext.Channels.FindAsync(channelId);
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync();
}
public Task<int> CountPlayouts(int channelId) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM Playout WHERE ChannelId = @ChannelId",
new { ChannelId = channelId });
public async Task<Unit> RemoveWatermark(Channel channel)
{
if (channel.Watermark != null)
{
await _dbConnection.ExecuteAsync(
"UPDATE Channel SET WatermarkId = NULL WHERE Id = @ChannelId",
new { ChannelId = channel.Id });
await _dbConnection.ExecuteAsync(
"DELETE FROM ChannelWatermark WHERE Id = @WatermarkId",
new { channel.WatermarkId });
channel.Watermark = null;
channel.WatermarkId = null;
}
return Unit.Default;
}
}
}
@@ -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;
+33 -1
View File
@@ -8,6 +8,8 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
@@ -18,10 +20,17 @@ namespace ErsatzTV.Infrastructure.Images
{
private static readonly SHA1CryptoServiceProvider Crypto;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ImageCache> _logger;
private readonly IMemoryCache _memoryCache;
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
public ImageCache(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
public ImageCache(ILocalFileSystem localFileSystem, IMemoryCache memoryCache, ILogger<ImageCache> logger)
{
_localFileSystem = localFileSystem;
_memoryCache = memoryCache;
_logger = logger;
}
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
{
@@ -120,5 +129,28 @@ namespace ErsatzTV.Infrastructure.Images
return Path.Combine(baseFolder, fileName);
}
public async Task<bool> IsAnimated(string fileName)
{
try
{
var cacheKey = $"image.animated.{Path.GetFileName(fileName)}";
if (_memoryCache.TryGetValue(cacheKey, out bool animated))
{
return animated;
}
using Image image = await Image.LoadAsync(fileName);
animated = image.Frames.Count > 1;
_memoryCache.Set(cacheKey, animated, TimeSpan.FromDays(1));
return animated;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to check image for animation");
return false;
}
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -235,6 +236,12 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
var version = new MediaVersion
{
Name = "Main",
@@ -543,6 +550,12 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
var version = new MediaVersion
{
Name = "Main",
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_EpisodeMetadataEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeNumber",
table: "EpisodeMetadata",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EpisodeNumber",
table: "EpisodeMetadata");
}
}
}
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_EpisodeMetadataEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE EpisodeMetadata SET EpisodeNumber = (SELECT EpisodeNumber FROM Episode WHERE Id = EpisodeMetadata.EpisodeId)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_EpisodeEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EpisodeNumber",
table: "Episode");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeNumber",
table: "Episode",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_LocalSeasonEtag : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL
WHERE LibraryPathId IN
(SELECT MI.LibraryPathId FROM MediaItem MI
INNER JOIN Season S on MI.Id = S.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
INNER JOIN Library L on LP.LibraryId = L.Id
INNER JOIN LocalLibrary LL on L.Id = LL.Id
WHERE L.MediaKind = 2)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Delete_JellyfinStrmFiles : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT MI.Id FROM MediaItem MI
INNER JOIN MediaVersion MV on MV.MovieId = MI.Id
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MF.Path LIKE '%.strm')");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ChannelWatermark : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "WatermarkId",
table: "Channel",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "ChannelWatermark",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Location = table.Column<int>(type: "INTEGER", nullable: false),
Size = table.Column<int>(type: "INTEGER", nullable: false),
Mode = table.Column<int>(type: "INTEGER", nullable: false),
WidthPercent = table.Column<int>(type: "INTEGER", nullable: false),
HorizontalMarginPercent = table.Column<int>(type: "INTEGER", nullable: false),
VerticalMarginPercent = table.Column<int>(type: "INTEGER", nullable: false),
FrequencyMinutes = table.Column<int>(type: "INTEGER", nullable: false),
DurationSeconds = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelWatermark", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Channel_WatermarkId",
table: "Channel",
column: "WatermarkId",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel");
migrationBuilder.DropTable(
name: "ChannelWatermark");
migrationBuilder.DropIndex(
name: "IX_Channel_WatermarkId",
table: "Channel");
migrationBuilder.DropColumn(
name: "WatermarkId",
table: "Channel");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_FFmpegProfileFrameRate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FrameRate",
table: "FFmpegProfile");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FrameRate",
table: "FFmpegProfile",
type: "TEXT",
nullable: true);
}
}
}
@@ -203,6 +203,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.Property<int?>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
@@ -210,9 +213,47 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("Number")
.IsUnique();
b.HasIndex("WatermarkId")
.IsUnique();
b.ToTable("Channel");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("DurationSeconds")
.HasColumnType("INTEGER");
b.Property<int>("FrequencyMinutes")
.HasColumnType("INTEGER");
b.Property<int>("HorizontalMarginPercent")
.HasColumnType("INTEGER");
b.Property<int>("Location")
.HasColumnType("INTEGER");
b.Property<int>("Mode")
.HasColumnType("INTEGER");
b.Property<int>("Size")
.HasColumnType("INTEGER");
b.Property<int>("VerticalMarginPercent")
.HasColumnType("INTEGER");
b.Property<int>("WidthPercent")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("ChannelWatermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Property<int>("Id")
@@ -348,6 +389,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("EpisodeId")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
@@ -403,9 +447,6 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<string>("FrameRate")
.HasColumnType("TEXT");
b.Property<int>("HardwareAcceleration")
.HasColumnType("INTEGER");
@@ -1421,9 +1462,6 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("SeasonId")
.HasColumnType("INTEGER");
@@ -1803,7 +1841,14 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithOne("Channel")
.HasForeignKey("ErsatzTV.Core.Domain.Channel", "WatermarkId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("FFmpegProfile");
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
@@ -2688,6 +2733,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Navigation("Channel");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Navigation("CollectionItems");

Some files were not shown because too many files have changed in this diff Show More