Compare commits

...
Author SHA1 Message Date
Jason Dove 23c4fcf42c update changelog for release 48 [no docker] 2021-06-22 20:05:43 -05:00
Jason DoveandGitHub e2f3e86fd6 fix adding jellyfin emby seasons episodes (#281)
* fix adding new seasons and episodes with emby and jellyfin

* update changelog

* update dependencies
2021-06-22 18:55:44 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fd9f4a8f4e Bump MudBlazor from 5.0.10 to 5.0.14 (#278)
Bumps [MudBlazor](https://github.com/Garderoben/MudBlazor) from 5.0.10 to 5.0.14.
- [Release notes](https://github.com/Garderoben/MudBlazor/releases)
- [Changelog](https://github.com/Garderoben/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/Garderoben/MudBlazor/compare/v5.0.10...v5.0.14)

---
updated-dependencies:
- dependency-name: MudBlazor
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-22 06:28:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5a0951a9b Bump FluentValidation from 10.1.0 to 10.2.3 (#275)
Bumps [FluentValidation](https://github.com/JeremySkinner/fluentvalidation) from 10.1.0 to 10.2.3.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.1.0...10.2.3)

---
updated-dependencies:
- dependency-name: FluentValidation
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-22 06:21:28 -05:00
Jason Dove 56d9724efd remove BOM from dependabot.yml [no docker] 2021-06-22 06:08:04 -05:00
Jason Dove f91b5ab3b5 assign dependabot prs [no docker] 2021-06-22 06:06:20 -05:00
Jason Dove 4b8e81ff06 add dependabot config [no docker] 2021-06-22 06:00:54 -05:00
Jason DoveandGitHub 1a7e6dda54 support 10-bit content with nvidia acceleration (#273)
* use ffprobe for plex statistics

* emby and jellyfin respect library refresh interval

* support 10-bit content with nvidia acceleration
2021-06-19 21:25:44 -05:00
28 changed files with 6336 additions and 152 deletions
+8
View File
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: daily
assignees:
- jasongdove
+16 -1
View File
@@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.48-prealpha] - 2021-06-22
### Added
- Store pixel format with media statistics; this is needed to support normalization of 10-bit media items
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use ffprobe to retrieve statistics for Plex media items (Local, Emby and Jellyfin libraries already use ffprobe)
### Fixed
- Fix playback of transcoded 10-bit media items (pixel format `yuv420p10le`) on Nvidia hardware
- Emby and Jellyfin scanners now respect library refresh interval setting
- Fix adding new seasons to existing Emby and Jellyfin shows
- Fix adding new episodes to existing Emby and Jellyfin seasons
## [0.0.47-prealpha] - 2021-06-15
### Added
- Add warning during playout rebuild when schedule has been emptied
@@ -468,7 +482,8 @@ 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.47-prealpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...HEAD
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
[0.0.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
[0.0.46-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
{
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeEmbyLibraryById request) =>
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath())
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, embyLibrary, ffprobePath) => new RequestParameters(
(connectionParameters, embyLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
embyLibrary,
request.ForceScan,
libraryRefreshInterval,
ffprobePath
));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Emby.Commands
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Emby.Commands
ConnectionParameters ConnectionParameters,
EmbyLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
{
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeJellyfinLibraryById request) =>
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath())
(await ValidateConnection(request), await JellyfinLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters(
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
jellyfinLibrary,
request.ForceScan,
libraryRefreshInterval,
ffprobePath
));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Jellyfin.Commands
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
ConnectionParameters ConnectionParameters,
JellyfinLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(
@@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -74,13 +75,15 @@ namespace ErsatzTV.Application.Plex.Commands
await _plexMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
parameters.Library,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _plexTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
parameters.Library,
parameters.FFprobePath);
break;
}
@@ -100,13 +103,14 @@ namespace ErsatzTV.Application.Plex.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
(await ValidateConnection(request), await PlexLibraryMustExist(request),
await ValidateLibraryRefreshInterval())
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, plexLibrary, libraryRefreshInterval) => new RequestParameters(
(connectionParameters, plexLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
plexLibrary,
request.ForceScan,
libraryRefreshInterval
libraryRefreshInterval,
ffprobePath
));
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
@@ -149,12 +153,20 @@ namespace ErsatzTV.Application.Plex.Commands
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(
ConnectionParameters ConnectionParameters,
PlexLibrary Library,
bool ForceScan,
int LibraryRefreshInterval);
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
{
@@ -12,6 +12,8 @@
public string Title { get; set; }
public bool Default { get; set; }
public bool Forced { get; set; }
public string PixelFormat { get; set; }
public int BitsPerRawSample { get; set; }
public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; }
}
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Emby
},
async () =>
{
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Emby
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
await _televisionRepository.AddSeason(incoming);
await _televisionRepository.AddSeason(show, incoming);
});
List<EmbyItemEtag> existingEpisodes =
@@ -364,7 +363,6 @@ namespace ErsatzTV.Core.Emby
try
{
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -373,7 +371,7 @@ namespace ErsatzTV.Core.Emby
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming))
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.FFmpeg
private IDisplaySize _resolution;
private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark;
private string _pixelFormat;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -63,6 +64,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
{
_pixelFormat = pixelFormat;
return this;
}
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
{
_watermark = watermark;
@@ -128,6 +135,8 @@ namespace ErsatzTV.Core.FFmpeg
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
@@ -150,6 +159,8 @@ namespace ErsatzTV.Core.FFmpeg
string format = acceleration switch
{
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12",
_ => "format=nv12"
};
videoFilterQueue.Add(format);
+4 -2
View File
@@ -175,7 +175,7 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec)
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec, string pixelFormat)
{
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
{
@@ -183,7 +183,9 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add(qsvCodec);
}
_complexFilterBuilder = _complexFilterBuilder.WithInputCodec(codec);
_complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat);
_arguments.Add("-i");
_arguments.Add($"{input}");
+1 -1
View File
@@ -62,7 +62,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat)
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration)
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library);
PlexLibrary library,
string ffprobePath);
}
}
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library);
PlexLibrary library,
string ffprobePath);
}
}
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId);
Task<bool> AddShow(EmbyShow show);
Task<Option<EmbyShow>> Update(EmbyShow show);
Task<bool> AddSeason(EmbySeason season);
Task<bool> AddSeason(EmbyShow show, EmbySeason season);
Task<Unit> Update(EmbySeason season);
Task<bool> AddEpisode(EmbyEpisode episode);
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds);
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId);
Task<bool> AddShow(JellyfinShow show);
Task<Option<JellyfinShow>> Update(JellyfinShow show);
Task<bool> AddSeason(JellyfinSeason season);
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
Task<Unit> Update(JellyfinSeason season);
Task<bool> AddEpisode(JellyfinEpisode episode);
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Jellyfin
},
async () =>
{
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Jellyfin
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
await _televisionRepository.AddSeason(incoming);
await _televisionRepository.AddSeason(show, incoming);
});
List<JellyfinItemEtag> existingEpisodes =
@@ -365,7 +364,6 @@ namespace ErsatzTV.Core.Jellyfin
try
{
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -374,7 +372,7 @@ namespace ErsatzTV.Core.Jellyfin
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming))
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
@@ -194,9 +194,15 @@ namespace ErsatzTV.Core.Metadata
MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.index,
Codec = videoStream.codec_name,
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant()
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant(),
PixelFormat = (videoStream.pix_fmt ?? string.Empty).ToLowerInvariant(),
};
if (int.TryParse(videoStream.bits_per_raw_sample, out int bitsPerRawSample))
{
stream.BitsPerRawSample = bitsPerRawSample;
}
if (videoStream.disposition is not null)
{
stream.Default = videoStream.disposition.@default == 1;
@@ -262,8 +268,10 @@ namespace ErsatzTV.Core.Metadata
int height,
string sample_aspect_ratio,
string display_aspect_ratio,
string pix_fmt,
string field_order,
string r_frame_rate,
string bits_per_raw_sample,
FFprobeDisposition disposition,
FFProbeTags tags);
// ReSharper restore InconsistentNaming
+37 -28
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -18,6 +17,7 @@ namespace ErsatzTV.Core.Plex
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
@@ -38,6 +38,7 @@ namespace ErsatzTV.Core.Plex
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -50,13 +51,15 @@ namespace ErsatzTV.Core.Plex
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library)
PlexLibrary library,
string ffprobePath)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
@@ -95,7 +98,7 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, library, connection, token))
.BindT(existing => UpdateStatistics(pathReplacements, existing, incoming, ffprobePath))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
@@ -144,11 +147,10 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
List<PlexPathReplacement> pathReplacements,
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
string ffprobePath)
{
PlexMovie existing = result.Item;
MediaVersion existingVersion = existing.MediaVersions.Head();
@@ -156,30 +158,37 @@ namespace ErsatzTV.Core.Plex
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async tuple =>
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await refreshResult.Match(
async _ =>
{
(MovieMetadata _, MediaVersion mediaVersion) = tuple;
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Statistics",
existingVersion.MediaFiles.Head().Path);
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
},
_ => Task.CompletedTask);
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
}
return result;
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Plex
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
@@ -40,6 +41,7 @@ namespace ErsatzTV.Core.Plex
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -52,13 +54,15 @@ namespace ErsatzTV.Core.Plex
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library)
PlexLibrary library,
string ffprobePath)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
@@ -85,7 +89,7 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
await ScanSeasons(library, pathReplacements, result.Item, connection, token);
await ScanSeasons(library, pathReplacements, result.Item, connection, token, ffprobePath);
if (result.IsAdded)
{
@@ -288,7 +292,8 @@ namespace ErsatzTV.Core.Plex
List<PlexPathReplacement> pathReplacements,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token)
PlexServerAuthToken token,
string ffprobePath)
{
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
library,
@@ -309,7 +314,13 @@ namespace ErsatzTV.Core.Plex
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(library, pathReplacements, season, connection, token),
async season => await ScanEpisodes(
library,
pathReplacements,
season,
connection,
token,
ffprobePath),
error =>
{
_logger.LogWarning(
@@ -373,7 +384,8 @@ namespace ErsatzTV.Core.Plex
List<PlexPathReplacement> pathReplacements,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token)
PlexServerAuthToken token,
string ffprobePath)
{
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
library,
@@ -414,11 +426,13 @@ namespace ErsatzTV.Core.Plex
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateStatistics(
pathReplacements,
existing,
incoming,
library,
connection,
token))
token,
ffprobePath))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeEpisode.Match(
@@ -484,57 +498,89 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
List<PlexPathReplacement> pathReplacements,
PlexEpisode existing,
PlexEpisode incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
PlexServerAuthToken token,
string ffprobePath)
{
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async tuple =>
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await refreshResult.Match(
async _ =>
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
foreach (EpisodeMetadata existingMetadata in maybeExisting)
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
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);
}
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
await maybeStatistics.Match(
async tuple =>
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
foreach (EpisodeMetadata existingMetadata in maybeExisting)
{
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);
}
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
},
_ => Task.CompletedTask);
},
_ => Task.CompletedTask);
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
}
return Right<BaseError, PlexEpisode>(existing);
@@ -242,18 +242,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return maybeExisting;
}
public async Task<bool> AddSeason(EmbySeason season)
public async Task<bool> AddSeason(EmbyShow show, EmbySeason season)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbyShow WHERE ItemId = @ItemId",
new { show.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Unit> Update(EmbySeason season)
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public async Task<bool> AddEpisode(EmbyEpisode episode)
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbySeason WHERE ItemId = @ItemId",
new { season.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Option<EmbyEpisode>> Update(EmbyEpisode episode)
@@ -259,18 +259,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return maybeExisting;
}
public async Task<bool> AddSeason(JellyfinSeason season)
public async Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId",
new { show.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Unit> Update(JellyfinSeason season)
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public async Task<bool> AddEpisode(JellyfinEpisode episode)
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId",
new { season.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode)
@@ -166,25 +166,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
() => Task.FromResult(false));
}
public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
{
bool updatedVersion = await _dbConnection.ExecuteAsync(
public Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming) =>
_dbConnection.ExecuteAsync(
@"UPDATE MediaVersion SET
SampleAspectRatio = @SampleAspectRatio,
VideoScanKind = @VideoScanKind,
DateUpdated = @DateUpdated
WHERE Id = @MediaVersionId",
new
{
incoming.SampleAspectRatio,
incoming.VideoScanKind,
incoming.DateUpdated,
MediaVersionId = mediaVersionId
}).Map(result => result > 0);
return await UpdateLocalStatistics(mediaVersionId, incoming, false) || updatedVersion;
}
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
@@ -16,12 +16,12 @@
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00013" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00013" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00013" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.7" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaStreamPixelFormat : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BitsPerRawSample",
table: "MediaStream",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "PixelFormat",
table: "MediaStream",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BitsPerRawSample",
table: "MediaStream");
migrationBuilder.DropColumn(
name: "PixelFormat",
table: "MediaStream");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_AllStatistics : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE LibraryFolder SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -733,6 +733,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BitsPerRawSample")
.HasColumnType("INTEGER");
b.Property<int>("Channels")
.HasColumnType("INTEGER");
@@ -757,6 +760,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("MediaVersionId")
.HasColumnType("INTEGER");
b.Property<string>("PixelFormat")
.HasColumnType("TEXT");
b.Property<string>("Profile")
.HasColumnType("TEXT");
+5 -5
View File
@@ -18,15 +18,15 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Blazored.LocalStorage" Version="4.1.1" />
<PackageReference Include="FluentValidation" Version="10.1.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.1.0" />
<PackageReference Include="FluentValidation" Version="10.2.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.2.3" />
<PackageReference Include="HtmlSanitizer" Version="5.0.404" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Markdig" Version="0.24.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -34,7 +34,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="5.0.10" />
<PackageReference Include="MudBlazor" Version="5.0.14" />
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.38" />
<PackageReference Include="Serilog" Version="2.10.0" />