diff --git a/CHANGELOG.md b/CHANGELOG.md index 5457d90cb..4d4627fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg) +### Changed +- Plex libraries now retrieve all metadata and statistics from Plex; ffprobe is no longer used +- Plex libraries now direct stream content from Plex when files are not found on ErsatzTV's file system + - Content will still be normalized according to the Channel and FFmpeg Profile settings + - Streaming from disk is preferred, so every playback attempt will first check the local file system + ## [0.7.4-beta] - 2023-02-12 ### Added - Add button to copy/clone schedule from schedules table diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 94f818477..6fb6a0957 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -1,4 +1,5 @@ using CliWrap; +using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; @@ -126,7 +127,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< .Include(i => i.Watermark) .ForChannelAndTime(channel.Id, now) .Map(o => o.ToEither(new UnableToLocatePlayoutItem())) - .BindT(ValidatePlayoutItemPath); + .BindT(item => ValidatePlayoutItemPath(dbContext, item)); if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem)) { @@ -440,13 +441,15 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< DisableWatermarks = !fallbackPreset.AllowWatermarks }; - return await ValidatePlayoutItemPath(playoutItem); + return await ValidatePlayoutItemPath(dbContext, playoutItem); } return new UnableToLocatePlayoutItem(); } - private async Task> ValidatePlayoutItemPath(PlayoutItem playoutItem) + private async Task> ValidatePlayoutItemPath( + TvContext dbContext, + PlayoutItem playoutItem) { string path = await GetPlayoutItemPath(playoutItem); @@ -455,14 +458,39 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< return new PlayoutItemWithPath(playoutItem, path); } + if (playoutItem.MediaItem.State == MediaItemState.RemoteOnly) + { + MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); + switch (file) + { + case PlexMediaFile pmf: + Option maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( + @"SELECT PMS.Id FROM PlexMediaSource PMS + INNER JOIN Library L on PMS.Id = L.MediaSourceId + INNER JOIN LibraryPath LP on L.Id = LP.LibraryId + WHERE LP.Id = @LibraryPathId", + new { playoutItem.MediaItem.LibraryPathId }) + .Map(Optional); + + foreach (int plexMediaSourceId in maybeId) + { + return new PlayoutItemWithPath( + playoutItem, + $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); + } + + break; + } + } + return new PlayoutItemDoesNotExistOnDisk(path); } private async Task GetPlayoutItemPath(PlayoutItem playoutItem) { MediaVersion version = playoutItem.MediaItem.GetHeadVersion(); - MediaFile file = version.MediaFiles.Head(); + string path = file.Path; return playoutItem.MediaItem switch { diff --git a/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs b/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs index 3f4884b87..bf46e1784 100644 --- a/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs +++ b/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs @@ -4,5 +4,6 @@ public enum MediaItemState { Normal = 0, FileNotFound = 1, - Unavailable = 2 + Unavailable = 2, + RemoteOnly = 3 } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 614da99f5..3b13c5582 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -140,10 +140,22 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService videoPath == audioPath ? playbackSettings.AudioDuration : Option.None, playbackSettings.NormalizeLoudness); + IPixelFormat pixelFormat = await AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger) + .IfNoneAsync( + () => + { + return videoStream.BitsPerRawSample switch + { + 8 => new PixelFormatYuv420P(), + 10 => new PixelFormatYuv420P10Le(), + _ => new PixelFormatUnknown(videoStream.BitsPerRawSample) + }; + }); + var ffmpegVideoStream = new VideoStream( videoStream.Index, videoStream.Codec, - AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger), + Some(pixelFormat), new ColorParams( videoStream.ColorRange, videoStream.ColorSpace, diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs index fb2b94a11..5d1655eb4 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs @@ -10,6 +10,7 @@ public interface IMediaServerMovieRepository where T Task> GetExistingMovies(TLibrary library); Task FlagNormal(TLibrary library, TMovie movie); Task> FlagUnavailable(TLibrary library, TMovie movie); + Task> FlagRemoteOnly(TLibrary library, TMovie movie); Task> FlagFileNotFound(TLibrary library, List movieItemIds); Task>> GetOrAdd(TLibrary library, TMovie item); Task SetEtag(TMovie movie, string etag); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs index 7e9c547a3..1b9479805 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs @@ -25,4 +25,5 @@ public interface IMediaServerTelevisionRepository> FlagFileNotFoundSeasons(TLibrary library, List seasonItemIds); Task> FlagFileNotFoundEpisodes(TLibrary library, List episodeItemIds); Task> FlagUnavailable(TLibrary library, TEpisode episode); + Task> FlagRemoteOnly(TLibrary library, TEpisode episode); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs index d7d540928..e55a9173b 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs @@ -12,7 +12,7 @@ public interface IMetadataRepository Task RemoveActor(Actor actor); Task Update(Domain.Metadata metadata); Task Add(Domain.Metadata metadata); - Task UpdateLocalStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true); + Task UpdateStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true); Task UpdateArtworkPath(Artwork artwork); Task AddArtwork(Domain.Metadata metadata, Artwork artwork); Task RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind); diff --git a/ErsatzTV.FFmpeg/Format/PixelFormatUnknown.cs b/ErsatzTV.FFmpeg/Format/PixelFormatUnknown.cs index 9b5b945af..19f767741 100644 --- a/ErsatzTV.FFmpeg/Format/PixelFormatUnknown.cs +++ b/ErsatzTV.FFmpeg/Format/PixelFormatUnknown.cs @@ -2,7 +2,12 @@ public class PixelFormatUnknown : IPixelFormat { + public PixelFormatUnknown(int bitDepth = 8) + { + BitDepth = bitDepth; + } + public string Name => "unknown"; public string FFmpegName => "unknown"; - public int BitDepth => 8; + public int BitDepth { get; } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs index 61b6e6b96..f2bdd323b 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs @@ -66,6 +66,28 @@ public class EmbyMovieRepository : IEmbyMovieRepository return None; } + public async Task> FlagRemoteOnly(EmbyLibrary library, EmbyMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.RemoteOnly; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT EmbyMovie.Id FROM EmbyMovie + INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbyMovie.ItemId = @ItemId", + new { LibraryId = library.Id, movie.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; } + public async Task> FlagFileNotFound(EmbyLibrary library, List movieItemIds) { if (movieItemIds.Count == 0) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs index fd05224e5..dfb2ca6bd 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs @@ -338,6 +338,29 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository return None; } + public async Task> FlagRemoteOnly(EmbyLibrary library, EmbyEpisode episode) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + episode.State = MediaItemState.RemoteOnly; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT EmbyEpisode.Id FROM EmbyEpisode + INNER JOIN MediaItem MI ON MI.Id = EmbyEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbyEpisode.ItemId = @ItemId", + new { LibraryId = library.Id, episode.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 3 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } + private async Task UpdateShow(TvContext dbContext, EmbyShow existing, EmbyShow incoming) { // library path is used for search indexing later diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs index 888e0eeac..21a46ca0e 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs @@ -67,6 +67,28 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository return None; } + public async Task> FlagRemoteOnly(JellyfinLibrary library, JellyfinMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.RemoteOnly; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT JellyfinMovie.Id FROM JellyfinMovie + INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinMovie.ItemId = @ItemId", + new { LibraryId = library.Id, movie.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 3 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; } + public async Task> FlagFileNotFound(JellyfinLibrary library, List movieItemIds) { if (movieItemIds.Count == 0) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs index 63073dbed..eee6e42d1 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs @@ -342,6 +342,28 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository return None; } + public async Task> FlagRemoteOnly(JellyfinLibrary library, JellyfinEpisode episode) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + episode.State = MediaItemState.RemoteOnly; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT JellyfinEpisode.Id FROM JellyfinEpisode + INNER JOIN MediaItem MI ON MI.Id = JellyfinEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinEpisode.ItemId = @ItemId", + new { LibraryId = library.Id, episode.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 3 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; } + private async Task UpdateShow(TvContext dbContext, JellyfinShow existing, JellyfinShow incoming) { // library path is used for search indexing later diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs index 0ba658b1f..71ba1b017 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs @@ -119,7 +119,7 @@ public class MetadataRepository : IMetadataRepository return await dbContext.SaveChangesAsync() > 0; } - public async Task UpdateLocalStatistics( + public async Task UpdateStatistics( MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs index 758f5f7a9..f72544197 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs @@ -66,6 +66,28 @@ public class PlexMovieRepository : IPlexMovieRepository return None; } + public async Task> FlagRemoteOnly(PlexLibrary library, PlexMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.RemoteOnly; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT PlexMovie.Id FROM PlexMovie + INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexMovie.Key = @Key", + new { LibraryId = library.Id, movie.Key }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 3 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } public async Task> FlagFileNotFound(PlexLibrary library, List plexMovieKeys) { if (plexMovieKeys.Count == 0) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs index bf5a613b3..7060a555a 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs @@ -84,6 +84,29 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository return None; } + public async Task> FlagRemoteOnly(PlexLibrary library, PlexEpisode episode) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + episode.State = MediaItemState.RemoteOnly; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT PlexEpisode.Id FROM PlexEpisode + INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexEpisode.Key = @Key", + new { LibraryId = library.Id, episode.Key }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 3 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } + public async Task> GetExistingShows(PlexLibrary library) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs index d177a72e1..f4b5d7ecb 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs @@ -19,6 +19,9 @@ public class PlexStreamResponse [XmlAttribute("languageCode")] public string LanguageCode { get; set; } + [XmlAttribute("title")] + public string Title { get; set; } + [XmlAttribute("streamType")] public int StreamType { get; set; } @@ -31,6 +34,12 @@ public class PlexStreamResponse [XmlAttribute("channels")] public int Channels { get; set; } + [XmlAttribute("width")] + public int Width { get; set; } + + [XmlAttribute("height")] + public int Height { get; set; } + [XmlAttribute("anamorphic")] public bool Anamorphic { get; set; } @@ -40,6 +49,24 @@ public class PlexStreamResponse [XmlAttribute("scanType")] public string ScanType { get; set; } + [XmlAttribute("frameRate")] + public string FrameRate { get; set; } + + [XmlAttribute("bitDepth")] + public int BitDepth { get; set; } + + [XmlAttribute("colorRange")] + public string ColorRange { get; set; } + + [XmlAttribute("colorSpace")] + public string ColorSpace { get; set; } + + [XmlAttribute("colorTrc")] + public string ColorTrc { get; set; } + + [XmlAttribute("colorPrimaries")] + public string ColorPrimaries { get; set; } + [XmlAttribute("displayTitle")] public string DisplayTitle { get; set; } diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index 5291a4ae9..b27cdd281 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -1,7 +1,7 @@ -using System.Xml.Serialization; +using System.Globalization; +using System.Xml.Serialization; using ErsatzTV.Core; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; @@ -541,7 +541,8 @@ public class PlexServerApiClient : IPlexServerApiClient private Option ProjectToMediaVersion(PlexXmlMetadataResponse response) { - List streams = response.Media.Head().Part.Head().Stream; + PlexMediaResponse media = response.Media.Head(); + List streams = media.Part.Head().Stream; DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; Option maybeVideoStream = streams.Find(s => s.StreamType == 1); return maybeVideoStream.Map( @@ -549,7 +550,9 @@ public class PlexServerApiClient : IPlexServerApiClient { var version = new MediaVersion { - SampleAspectRatio = videoStream.PixelAspectRatio ?? "1:1", + Duration = TimeSpan.FromMilliseconds(media.Duration), + SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.PixelAspectRatio) ? "1:1" + : videoStream.PixelAspectRatio, VideoScanKind = videoStream.ScanType switch { "interlaced" => VideoScanKind.Interlaced, @@ -557,21 +560,34 @@ public class PlexServerApiClient : IPlexServerApiClient _ => VideoScanKind.Unknown }, Streams = new List(), - DateUpdated = dateUpdated + DateUpdated = dateUpdated, + Width = videoStream.Width, + Height = videoStream.Height, + RFrameRate = videoStream.FrameRate, + DisplayAspectRatio = media.AspectRatio == 0 + ? string.Empty + : media.AspectRatio.ToString("0.00###", CultureInfo.InvariantCulture), + Chapters = new List() // TODO: `?includeChapters=1` }; version.Streams.Add( new MediaStream { + MediaVersionId = version.Id, MediaStreamKind = MediaStreamKind.Video, Index = videoStream.Index, Codec = videoStream.Codec, Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(), Default = videoStream.Default, Language = videoStream.LanguageCode, - Forced = videoStream.Forced + Forced = videoStream.Forced, + BitsPerRawSample = videoStream.BitDepth, + ColorRange = (videoStream.ColorRange ?? string.Empty).ToLowerInvariant(), + ColorSpace = (videoStream.ColorSpace ?? string.Empty).ToLowerInvariant(), + ColorTransfer = (videoStream.ColorTrc ?? string.Empty).ToLowerInvariant(), + ColorPrimaries = (videoStream.ColorPrimaries ?? string.Empty).ToLowerInvariant() }); - + foreach (PlexStreamResponse audioStream in streams.Filter(s => s.StreamType == 2)) { var stream = new MediaStream @@ -584,9 +600,10 @@ public class PlexServerApiClient : IPlexServerApiClient Channels = audioStream.Channels, Default = audioStream.Default, Forced = audioStream.Forced, - Language = audioStream.LanguageCode + Language = audioStream.LanguageCode, + Title = audioStream.Title ?? string.Empty }; - + version.Streams.Add(stream); } diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index b082ec49c..b809a376f 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -283,7 +283,7 @@ public class TranscodingTests var metadataRepository = new Mock(); metadataRepository - .Setup(r => r.UpdateLocalStatistics(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(r => r.UpdateStatistics(It.IsAny(), It.IsAny(), It.IsAny())) .Callback( (_, version, _) => { diff --git a/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs index e432ad6bb..3781a852f 100644 --- a/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs @@ -28,6 +28,7 @@ public class EmbyMovieLibraryScanner : IEmbyMovieRepository embyMovieRepository, IEmbyPathReplacementService pathReplacementService, ILocalFileSystem localFileSystem, + IMetadataRepository metadataRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, ILogger logger) @@ -35,6 +36,7 @@ public class EmbyMovieLibraryScanner : localStatisticsProvider, localSubtitlesProvider, localFileSystem, + metadataRepository, mediator, logger) { @@ -103,6 +105,12 @@ public class EmbyMovieLibraryScanner : bool deepScan) => Task.FromResult>(None); + protected override Task>> GetFullMetadataAndStatistics( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library, + MediaItemScanResult result, + EmbyMovie incoming) => Task.FromResult(Option>.None); + protected override Task>> UpdateMetadata( MediaItemScanResult result, MovieMetadata fullMetadata) => diff --git a/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs index daf4c1bb8..eeb4f62f1 100644 --- a/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs @@ -27,6 +27,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< IEmbyTelevisionRepository televisionRepository, IEmbyPathReplacementService pathReplacementService, ILocalFileSystem localFileSystem, + IMetadataRepository metadataRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, IMediator mediator, @@ -35,6 +36,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< localStatisticsProvider, localSubtitlesProvider, localFileSystem, + metadataRepository, mediator, logger) { @@ -163,6 +165,12 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner< bool deepScan) => Task.FromResult(Option.None); + protected override Task>> GetFullMetadataAndStatistics( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library, + MediaItemScanResult result, + EmbyEpisode incoming) => Task.FromResult(Option>.None); + protected override Task>> UpdateMetadata( MediaItemScanResult result, ShowMetadata fullMetadata) => diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs index d518abe7c..653500a87 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs @@ -28,6 +28,7 @@ public class JellyfinMovieLibraryScanner : IJellyfinPathReplacementService pathReplacementService, IMediaSourceRepository mediaSourceRepository, ILocalFileSystem localFileSystem, + IMetadataRepository metadataRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, ILogger logger) @@ -35,6 +36,7 @@ public class JellyfinMovieLibraryScanner : localStatisticsProvider, localSubtitlesProvider, localFileSystem, + metadataRepository, mediator, logger) { @@ -106,6 +108,12 @@ public class JellyfinMovieLibraryScanner : bool deepScan) => Task.FromResult>(None); + protected override Task>> GetFullMetadataAndStatistics( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinMovie incoming) => Task.FromResult(Option>.None); + protected override Task>> UpdateMetadata( MediaItemScanResult result, MovieMetadata fullMetadata) => diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs index 0cb674c40..339bb8265 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs @@ -28,6 +28,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan IJellyfinTelevisionRepository televisionRepository, IJellyfinPathReplacementService pathReplacementService, ILocalFileSystem localFileSystem, + IMetadataRepository metadataRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, IMediator mediator, @@ -36,6 +37,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan localStatisticsProvider, localSubtitlesProvider, localFileSystem, + metadataRepository, mediator, logger) { @@ -169,6 +171,12 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan bool deepScan) => Task.FromResult(Option.None); + protected override Task>> GetFullMetadataAndStatistics( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinEpisode incoming) => Task.FromResult(Option>.None); + protected override Task>> UpdateMetadata( MediaItemScanResult result, ShowMetadata fullMetadata) => diff --git a/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs index a1b1aa408..2c2fcddf1 100644 --- a/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Scanner/Core/Metadata/LocalStatisticsProvider.cs @@ -214,7 +214,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath); - return await _metadataRepository.UpdateLocalStatistics(mediaItem, version) && durationChange; + return await _metadataRepository.UpdateStatistics(mediaItem, version) && durationChange; } private async Task> GetProbeOutput(string ffprobePath, string filePath) diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs index 34efa33cd..e57081c96 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs @@ -19,6 +19,7 @@ public abstract class MediaServerMovieLibraryScanner false; + protected virtual bool ServerReturnsStatisticsWithMetadata => false; protected async Task> ScanLibrary( IMediaServerMovieRepository movieRepository, @@ -121,18 +127,51 @@ public abstract class MediaServerMovieLibraryScanner> maybeMovie; - Either> maybeMovie = await movieRepository - .GetOrAdd(library, incoming) - .MapT( - result => - { - result.LocalPath = localPath; - return result; - }) - .BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)) - .BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath)) - .BindT(UpdateSubtitles); + if (ServerReturnsStatisticsWithMetadata) + { + maybeMovie = await movieRepository + .GetOrAdd(library, incoming) + .MapT( + result => + { + result.LocalPath = localPath; + return result; + }) + .BindT( + existing => UpdateMetadataAndStatistics( + connectionParameters, + library, + existing, + incoming, + deepScan)) + .BindT(UpdateSubtitles); + } + else + { + maybeMovie = await movieRepository + .GetOrAdd(library, incoming) + .MapT( + result => + { + result.LocalPath = localPath; + return result; + }) + .BindT( + existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan, None)) + .BindT( + existing => UpdateStatistics( + connectionParameters, + library, + existing, + incoming, + ffmpegPath, + ffprobePath, + None)) + .BindT(UpdateSubtitles); + } if (maybeMovie.IsLeft) { @@ -158,6 +197,14 @@ public abstract class MediaServerMovieLibraryScanner flagResult = await movieRepository.FlagRemoteOnly(library, result.Item); + if (flagResult.IsSome) + { + result.IsUpdated = true; + } + } else { Option flagResult = await movieRepository.FlagUnavailable(library, result.Item); @@ -218,6 +265,18 @@ public abstract class MediaServerMovieLibraryScanner> GetMediaServerStatistics( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TMovie incoming) => Task.FromResult(Option.None); + + protected abstract Task>> GetFullMetadataAndStatistics( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TMovie incoming); + protected abstract Task>> UpdateMetadata( MediaItemScanResult result, MovieMetadata fullMetadata); @@ -259,11 +318,23 @@ public abstract class MediaServerMovieLibraryScanner()), - CancellationToken.None); + foreach (int id in await movieRepository.FlagRemoteOnly(library, incoming)) + { + await _mediator.Publish( + new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty()), + CancellationToken.None); + } + } + else + { + foreach (int id in await movieRepository.FlagUnavailable(library, incoming)) + { + await _mediator.Publish( + new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty()), + CancellationToken.None); + } } } @@ -282,19 +353,76 @@ public abstract class MediaServerMovieLibraryScanner>> UpdateMetadata( + private async Task>> UpdateMetadataAndStatistics( TConnectionParameters connectionParameters, TLibrary library, MediaItemScanResult result, TMovie incoming, bool deepScan) { - foreach (MovieMetadata fullMetadata in await GetFullMetadata( - connectionParameters, - library, - result, - incoming, - deepScan)) + Option> maybeMetadataAndStatistics = await GetFullMetadataAndStatistics( + connectionParameters, + library, + result, + incoming); + + foreach ((MovieMetadata fullMetadata, MediaVersion mediaVersion) in maybeMetadataAndStatistics) + { + Either> metadataResult = await UpdateMetadata( + connectionParameters, + library, + result, + incoming, + deepScan, + fullMetadata); + + foreach (BaseError error in metadataResult.LeftToSeq()) + { + return error; + } + + foreach (MediaItemScanResult r in metadataResult.RightToSeq()) + { + result = r; + } + + Either> statisticsResult = await UpdateStatistics( + connectionParameters, + library, + result, + incoming, + string.Empty, + string.Empty, + mediaVersion); + + foreach (BaseError error in statisticsResult.LeftToSeq()) + { + return error; + } + + foreach (MediaItemScanResult r in metadataResult.RightToSeq()) + { + result = r; + } + } + + return result; + } + + private async Task>> UpdateMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TMovie incoming, + bool deepScan, + Option maybeFullMetadata) + { + if (maybeFullMetadata.IsNone) + { + maybeFullMetadata = await GetFullMetadata(connectionParameters, library, result, incoming, deepScan); + } + + foreach (MovieMetadata fullMetadata in maybeFullMetadata) { // TODO: move some of this code into this scanner // will have to merge JF, Emby, Plex logic @@ -305,17 +433,20 @@ public abstract class MediaServerMovieLibraryScanner>> UpdateStatistics( + TConnectionParameters connectionParameters, + TLibrary library, MediaItemScanResult result, TMovie incoming, string ffmpegPath, - string ffprobePath) + string ffprobePath, + Option maybeMediaVersion) { TMovie existing = result.Item; if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || existing.MediaVersions.Head().Streams.Count == 0) { - if (_localFileSystem.FileExists(result.LocalPath)) + if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath)) { _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); Either refreshResult = @@ -339,6 +470,25 @@ public abstract class MediaServerMovieLibraryScanner false; + protected virtual bool ServerReturnsStatisticsWithMetadata => false; + protected async Task> ScanLibrary( IMediaServerTelevisionRepository televisionRepository, TConnectionParameters connectionParameters, @@ -268,6 +274,18 @@ public abstract class MediaServerTelevisionLibraryScanner> GetMediaServerStatistics( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TEpisode incoming) => Task.FromResult(Option.None); + + protected abstract Task>> GetFullMetadataAndStatistics( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TEpisode incoming); + protected abstract Task>> UpdateMetadata( MediaItemScanResult result, ShowMetadata fullMetadata); @@ -279,7 +297,7 @@ public abstract class MediaServerTelevisionLibraryScanner>> UpdateMetadata( MediaItemScanResult result, EpisodeMetadata fullMetadata); - + private async Task> ScanSeasons( IMediaServerTelevisionRepository televisionRepository, TLibrary library, @@ -436,17 +454,50 @@ public abstract class MediaServerTelevisionLibraryScanner> maybeEpisode = await televisionRepository - .GetOrAdd(library, incoming, deepScan) - .MapT( - result => - { - result.LocalPath = localPath; - return result; - }) - .BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)) - .BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath)) - .BindT(UpdateSubtitles); + Either> maybeEpisode; + + if (ServerReturnsStatisticsWithMetadata) + { + maybeEpisode = await televisionRepository + .GetOrAdd(library, incoming, deepScan) + .MapT( + result => + { + result.LocalPath = localPath; + return result; + }) + .BindT( + existing => UpdateMetadataAndStatistics( + connectionParameters, + library, + existing, + incoming, + deepScan)) + .BindT(UpdateSubtitles); + } + else + { + maybeEpisode = await televisionRepository + .GetOrAdd(library, incoming, deepScan) + .MapT( + result => + { + result.LocalPath = localPath; + return result; + }) + .BindT( + existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan, None)) + .BindT( + existing => UpdateStatistics( + connectionParameters, + library, + existing, + incoming, + ffmpegPath, + ffprobePath, + None)) + .BindT(UpdateSubtitles); + } if (maybeEpisode.IsLeft) { @@ -474,6 +525,14 @@ public abstract class MediaServerTelevisionLibraryScanner flagResult = await televisionRepository.FlagRemoteOnly(library, result.Item); + if (flagResult.IsSome) + { + result.IsUpdated = true; + } + } else { Option flagResult = await televisionRepository.FlagUnavailable(library, result.Item); @@ -542,11 +601,22 @@ public abstract class MediaServerTelevisionLibraryScanner()), + CancellationToken.None); + } + } + else { - await _mediator.Publish( - new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty()), - CancellationToken.None); + foreach (int id in await televisionRepository.FlagUnavailable(library, incoming)) + { + await _mediator.Publish( + new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty()), + CancellationToken.None); + } } } @@ -617,19 +687,76 @@ public abstract class MediaServerTelevisionLibraryScanner>> UpdateMetadata( + private async Task>> UpdateMetadataAndStatistics( TConnectionParameters connectionParameters, TLibrary library, MediaItemScanResult result, TEpisode incoming, bool deepScan) { - foreach (EpisodeMetadata fullMetadata in await GetFullMetadata( - connectionParameters, - library, - result, - incoming, - deepScan)) + Option> maybeMetadataAndStatistics = await GetFullMetadataAndStatistics( + connectionParameters, + library, + result, + incoming); + + foreach ((EpisodeMetadata fullMetadata, MediaVersion mediaVersion) in maybeMetadataAndStatistics) + { + Either> metadataResult = await UpdateMetadata( + connectionParameters, + library, + result, + incoming, + deepScan, + fullMetadata); + + foreach (BaseError error in metadataResult.LeftToSeq()) + { + return error; + } + + foreach (MediaItemScanResult r in metadataResult.RightToSeq()) + { + result = r; + } + + Either> statisticsResult = await UpdateStatistics( + connectionParameters, + library, + result, + incoming, + string.Empty, + string.Empty, + mediaVersion); + + foreach (BaseError error in statisticsResult.LeftToSeq()) + { + return error; + } + + foreach (MediaItemScanResult r in metadataResult.RightToSeq()) + { + result = r; + } + } + + return result; + } + + private async Task>> UpdateMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TEpisode incoming, + bool deepScan, + Option maybeFullMetadata) + { + if (maybeFullMetadata.IsNone) + { + maybeFullMetadata = await GetFullMetadata(connectionParameters, library, result, incoming, deepScan); + } + + foreach (EpisodeMetadata fullMetadata in maybeFullMetadata) { // TODO: move some of this code into this scanner // will have to merge JF, Emby, Plex logic @@ -640,17 +767,20 @@ public abstract class MediaServerTelevisionLibraryScanner>> UpdateStatistics( + TConnectionParameters connectionParameters, + TLibrary library, MediaItemScanResult result, TEpisode incoming, string ffmpegPath, - string ffprobePath) + string ffprobePath, + Option maybeMediaVersion) { TEpisode existing = result.Item; if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || existing.MediaVersions.Head().Streams.Count == 0) { - if (_localFileSystem.FileExists(result.LocalPath)) + if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath)) { _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); Either refreshResult = @@ -674,6 +804,25 @@ public abstract class MediaServerTelevisionLibraryScanner true; + protected override bool ServerReturnsStatisticsWithMetadata => true; + public async Task> ScanLibrary( PlexConnection connection, PlexServerAuthToken token, @@ -129,6 +133,53 @@ public class PlexMovieLibraryScanner : return None; } + protected override async Task> GetMediaServerStatistics( + PlexConnectionParameters connectionParameters, + PlexLibrary library, + MediaItemScanResult result, + PlexMovie incoming) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Statistics", result.LocalPath); + + Either maybeVersion = + await _plexServerApiClient.GetMovieMetadataAndStatistics( + library, + incoming.Key.Split("/").Last(), + connectionParameters.Connection, + connectionParameters.Token) + .MapT(tuple => tuple.Item2); // drop the metadata part + + foreach (BaseError error in maybeVersion.LeftToSeq()) + { + _logger.LogWarning("Failed to get movie statistics from Plex: {Error}", error.ToString()); + } + + return maybeVersion.ToOption(); + } + + protected override async Task>> GetFullMetadataAndStatistics( + PlexConnectionParameters connectionParameters, + PlexLibrary library, + MediaItemScanResult result, + PlexMovie incoming) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Metadata and Statistics", result.LocalPath); + + Either> maybeResult = + await _plexServerApiClient.GetMovieMetadataAndStatistics( + library, + incoming.Key.Split("/").Last(), + connectionParameters.Connection, + connectionParameters.Token); + + foreach (BaseError error in maybeResult.LeftToSeq()) + { + _logger.LogWarning("Failed to get movie metadata and statistics from Plex: {Error}", error.ToString()); + } + + return maybeResult.ToOption(); + } + protected override async Task>> UpdateMetadata( MediaItemScanResult result, MovieMetadata fullMetadata) @@ -136,11 +187,6 @@ public class PlexMovieLibraryScanner : PlexMovie existing = result.Item; MovieMetadata existingMetadata = existing.MovieMetadata.Head(); - _logger.LogDebug( - "Refreshing {Attribute} for {Title}", - "Plex Metadata", - existingMetadata.Title); - if (existingMetadata.MetadataKind != MetadataKind.External) { existingMetadata.MetadataKind = MetadataKind.External; diff --git a/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs index bf991ecf5..ca4dd3706 100644 --- a/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs @@ -40,6 +40,7 @@ public class PlexTelevisionLibraryScanner : localStatisticsProvider, localSubtitlesProvider, localFileSystem, + metadataRepository, mediator, logger) { @@ -52,6 +53,9 @@ public class PlexTelevisionLibraryScanner : _logger = logger; } + protected override bool ServerSupportsRemoteStreaming => true; + protected override bool ServerReturnsStatisticsWithMetadata => true; + public async Task> ScanLibrary( PlexConnection connection, PlexServerAuthToken token, @@ -259,6 +263,53 @@ public class PlexTelevisionLibraryScanner : return None; } + protected override async Task> GetMediaServerStatistics( + PlexConnectionParameters connectionParameters, + PlexLibrary library, + MediaItemScanResult result, + PlexEpisode incoming) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Statistics", result.LocalPath); + + Either maybeVersion = + await _plexServerApiClient.GetEpisodeMetadataAndStatistics( + library, + incoming.Key.Split("/").Last(), + connectionParameters.Connection, + connectionParameters.Token) + .MapT(tuple => tuple.Item2); // drop the metadata part + + foreach (BaseError error in maybeVersion.LeftToSeq()) + { + _logger.LogWarning("Failed to get episode statistics from Plex: {Error}", error.ToString()); + } + + return maybeVersion.ToOption(); + } + + protected override async Task>> GetFullMetadataAndStatistics( + PlexConnectionParameters connectionParameters, + PlexLibrary library, + MediaItemScanResult result, + PlexEpisode incoming) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Metadata and Statistics", result.LocalPath); + + Either> maybeResult = + await _plexServerApiClient.GetEpisodeMetadataAndStatistics( + library, + incoming.Key.Split("/").Last(), + connectionParameters.Connection, + connectionParameters.Token); + + foreach (BaseError error in maybeResult.LeftToSeq()) + { + _logger.LogWarning("Failed to get episode metadata and statistics from Plex: {Error}", error.ToString()); + } + + return maybeResult.ToOption(); + } + protected override async Task>> UpdateMetadata( MediaItemScanResult result, ShowMetadata fullMetadata) diff --git a/ErsatzTV.sln b/ErsatzTV.sln index 62fa6d91b..ed057df68 100644 --- a/ErsatzTV.sln +++ b/ErsatzTV.sln @@ -33,8 +33,8 @@ Global {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU - {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -79,10 +79,10 @@ Global {591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.ActiveCfg = Release|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.Build.0 = Release|Any CPU - {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU - {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU + {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index d4ed731eb..d8651a370 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -1,10 +1,13 @@ using System.Diagnostics; using CliWrap; +using ErsatzTV.Application.Plex; using ErsatzTV.Application.Streaming; +using ErsatzTV.Core; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Mvc; +using Flurl; namespace ErsatzTV.Controllers; @@ -14,10 +17,15 @@ public class InternalController : ControllerBase { private readonly ILogger _logger; private readonly IMediator _mediator; + private readonly IHttpClientFactory _httpClientFactory; - public InternalController(IMediator mediator, ILogger logger) + public InternalController( + IMediator mediator, + IHttpClientFactory httpClientFactory, + ILogger logger) { _mediator = mediator; + _httpClientFactory = httpClientFactory; _logger = logger; } @@ -79,4 +87,22 @@ public class InternalController : ControllerBase return BadRequest(error.Value); } )); + + [HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")] + public async Task GetPlexFanArt( + int plexMediaSourceId, + string path, + CancellationToken cancellationToken) + { + Either connectionParameters = + await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId), cancellationToken); + + return connectionParameters.Match( + Left: _ => new NotFoundResult(), + Right: r => + { + Url fullPath = new Uri(r.Uri, path).SetQueryParam("X-Plex-Token", r.AuthToken); + return new RedirectResult(fullPath.ToString()); + }); + } }