diff --git a/ErsatzTV.Application/Plex/Commands/SignOutOfPlex.cs b/ErsatzTV.Application/Plex/Commands/SignOutOfPlex.cs new file mode 100644 index 000000000..926f318ed --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/SignOutOfPlex.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Plex.Commands +{ + public record SignOutOfPlex : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs b/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs new file mode 100644 index 000000000..f6ac7411c --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.Plex.Commands +{ + public class SignOutOfPlexHandler : MediatR.IRequestHandler> + { + private readonly IEntityLocker _entityLocker; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IPlexSecretStore _plexSecretStore; + + public SignOutOfPlexHandler( + IMediaSourceRepository mediaSourceRepository, + IPlexSecretStore plexSecretStore, + IEntityLocker entityLocker) + { + _mediaSourceRepository = mediaSourceRepository; + _plexSecretStore = plexSecretStore; + _entityLocker = entityLocker; + } + + public async Task> Handle(SignOutOfPlex request, CancellationToken cancellationToken) + { + await _mediaSourceRepository.DeleteAllPlex(); + await _plexSecretStore.DeleteAll(); + _entityLocker.UnlockPlex(); + + return Unit.Default; + } + } +} diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs index 16bc46a52..9640fc735 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs @@ -3,5 +3,6 @@ using LanguageExt; namespace ErsatzTV.Application.Plex.Commands { - public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest>; + public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest>, + IPlexBackgroundServiceRequest; } diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs index a1e45e261..2d6b4c441 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; @@ -15,15 +17,21 @@ namespace ErsatzTV.Application.Plex.Commands SynchronizePlexMediaSourcesHandler : IRequestHandler>> { + private readonly ChannelWriter _channel; + private readonly IEntityLocker _entityLocker; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IPlexTvApiClient _plexTvApiClient; public SynchronizePlexMediaSourcesHandler( IMediaSourceRepository mediaSourceRepository, - IPlexTvApiClient plexTvApiClient) + IPlexTvApiClient plexTvApiClient, + ChannelWriter channel, + IEntityLocker entityLocker) { _mediaSourceRepository = mediaSourceRepository; _plexTvApiClient = plexTvApiClient; + _channel = channel; + _entityLocker = entityLocker; } public Task>> Handle( @@ -39,6 +47,13 @@ namespace ErsatzTV.Application.Plex.Commands await SynchronizeServer(allExisting, server); } + foreach (PlexMediaSource mediaSource in await _mediaSourceRepository.GetAllPlex()) + { + await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id)); + } + + _entityLocker.UnlockPlex(); + return allExisting; } diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 5145494d6..d176382aa 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -6,10 +6,13 @@ using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; namespace ErsatzTV.Application.Streaming.Queries { @@ -17,6 +20,7 @@ namespace ErsatzTV.Application.Streaming.Queries GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { private readonly FFmpegProcessService _ffmpegProcessService; + private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IPlayoutRepository _playoutRepository; @@ -27,12 +31,14 @@ namespace ErsatzTV.Application.Streaming.Queries IPlayoutRepository playoutRepository, IMediaSourceRepository mediaSourceRepository, FFmpegProcessService ffmpegProcessService, + ILocalFileSystem localFileSystem, ILogger logger) : base(channelRepository, configElementRepository) { _playoutRepository = playoutRepository; _mediaSourceRepository = mediaSourceRepository; _ffmpegProcessService = ffmpegProcessService; + _localFileSystem = localFileSystem; _logger = logger; } @@ -42,49 +48,124 @@ namespace ErsatzTV.Application.Streaming.Queries string ffmpegPath) { DateTimeOffset now = DateTimeOffset.Now; - Option maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now); - return await maybePlayoutItem.Match>>( - async playoutItem => + Either maybePlayoutItem = await _playoutRepository + .GetPlayoutItem(channel.Id, now) + .Map(o => o.ToEither(new UnableToLocatePlayoutItem())) + .BindT(ValidatePlayoutItemPath); + + return await maybePlayoutItem.Match( + playoutItemWithPath => { - MediaVersion version = playoutItem.MediaItem switch + MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch { Movie m => m.MediaVersions.Head(), Episode e => e.MediaVersions.Head(), - _ => throw new ArgumentOutOfRangeException(nameof(playoutItem)) + _ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath)) }; - MediaFile file = version.MediaFiles.Head(); - string path = file.Path; - if (playoutItem.MediaItem is PlexMovie plexMovie) - { - path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path); - } - - return _ffmpegProcessService.ForPlayoutItem( - ffmpegPath, - channel, - version, - path, - playoutItem.StartOffset, - now); + return Right( + _ffmpegProcessService.ForPlayoutItem( + ffmpegPath, + channel, + version, + playoutItemWithPath.Path, + playoutItemWithPath.PlayoutItem.StartOffset, + now)).AsTask(); }, - async () => + async error => { - if (channel.FFmpegProfile.Transcode) + var offlineTranscodeMessage = + $"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'"; + + Option maybeDuration = await Optional(channel.FFmpegProfile.Transcode) + .Filter(transcode => transcode) + .Match( + _ => _playoutRepository.GetNextItemStart(channel.Id, now) + .MapT(nextStart => nextStart - now), + () => Option.None.AsTask()); + + switch (error) { - Option maybeDuration = await _playoutRepository.GetNextItemStart(channel.Id, now) - .MapT(nextStart => nextStart - now); + case UnableToLocatePlayoutItem: + if (channel.FFmpegProfile.Transcode) + { + return _ffmpegProcessService.ForError( + ffmpegPath, + channel, + maybeDuration, + "Channel is Offline"); + } + else + { + var message = + $"Unable to locate playout item for channel {channel.Number}; {offlineTranscodeMessage}"; - return _ffmpegProcessService.ForOfflineImage(ffmpegPath, channel, maybeDuration); - } + return BaseError.New(message); + } + case PlayoutItemDoesNotExistOnDisk: + if (channel.FFmpegProfile.Transcode) + { + return _ffmpegProcessService.ForError(ffmpegPath, channel, maybeDuration, error.Value); + } + else + { + var message = + $"Playout item does not exist on disk for channel {channel.Number}; {offlineTranscodeMessage}"; - var message = - $"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'"; + return BaseError.New(message); + } + default: + if (channel.FFmpegProfile.Transcode) + { + return _ffmpegProcessService.ForError( + ffmpegPath, + channel, + maybeDuration, + "Channel is Offline"); + } + else + { + var message = + $"Unexpected error locating playout item for channel {channel.Number}; {offlineTranscodeMessage}"; - return BaseError.New(message); + return BaseError.New(message); + } + } }); } + private async Task> ValidatePlayoutItemPath(PlayoutItem playoutItem) + { + string path = await GetPlayoutItemPath(playoutItem); + + // TODO: this won't work with url streaming from plex + if (_localFileSystem.FileExists(path)) + { + return new PlayoutItemWithPath(playoutItem, path); + } + + return new PlayoutItemDoesNotExistOnDisk(path); + } + + private async Task GetPlayoutItemPath(PlayoutItem playoutItem) + { + MediaVersion version = playoutItem.MediaItem switch + { + Movie m => m.MediaVersions.Head(), + Episode e => e.MediaVersions.Head(), + _ => throw new ArgumentOutOfRangeException(nameof(playoutItem)) + }; + + MediaFile file = version.MediaFiles.Head(); + string path = file.Path; + if (playoutItem.MediaItem is PlexMovie plexMovie) + { + path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path); + } + + return path; + } + private async Task GetReplacementPlexPath(int libraryPathId, string path) { List replacements = @@ -105,5 +186,7 @@ namespace ErsatzTV.Application.Streaming.Queries }, () => path); } + + private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path); } } diff --git a/ErsatzTV.Core/Errors/PlayoutItemDoesNotExistOnDisk.cs b/ErsatzTV.Core/Errors/PlayoutItemDoesNotExistOnDisk.cs new file mode 100644 index 000000000..cea5e62a8 --- /dev/null +++ b/ErsatzTV.Core/Errors/PlayoutItemDoesNotExistOnDisk.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Errors +{ + public class PlayoutItemDoesNotExistOnDisk : BaseError + { + public PlayoutItemDoesNotExistOnDisk(string path) : base($"Playout item does not exist on disk\n{path}") + { + } + } +} diff --git a/ErsatzTV.Core/Errors/UnableToLocatePlayoutItem.cs b/ErsatzTV.Core/Errors/UnableToLocatePlayoutItem.cs new file mode 100644 index 000000000..a21035828 --- /dev/null +++ b/ErsatzTV.Core/Errors/UnableToLocatePlayoutItem.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Errors +{ + public class UnableToLocatePlayoutItem : BaseError + { + public UnableToLocatePlayoutItem() : base("Unable to locate playout item") + { + } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index f06d4aeec..5d6aeb774 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -84,7 +84,7 @@ namespace ErsatzTV.Core.FFmpeg .Build(); } - public Process ForOfflineImage(string ffmpegPath, Channel channel, Option duration) + public Process ForError(string ffmpegPath, Channel channel, Option duration, string errorMessage) { FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile); @@ -99,7 +99,7 @@ namespace ErsatzTV.Core.FFmpeg .WithLoopedImage("Resources/background.png") .WithLibavfilter() .WithInput("anullsrc") - .WithErrorText(desiredResolution, "Channel is Offline") + .WithErrorText(desiredResolution, errorMessage) .WithPixfmt("yuv420p") .WithPlaybackArgs(playbackSettings) .WithMetadata(channel) diff --git a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs index fc8676545..829889584 100644 --- a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs +++ b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs @@ -5,8 +5,12 @@ namespace ErsatzTV.Core.Interfaces.Locking public interface IEntityLocker { event EventHandler OnLibraryChanged; + event EventHandler OnPlexChanged; bool LockLibrary(int libraryId); bool UnlockLibrary(int libraryId); bool IsLibraryLocked(int libraryId); + bool LockPlex(); + bool UnlockPlex(); + bool IsPlexLocked(); } } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs index cf6973b82..045d98499 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs @@ -10,8 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Plex Task GetClientIdentifier(); Task> GetUserAuthTokens(); Task UpsertUserAuthToken(PlexUserAuthToken userAuthToken); - Task> GetServerAuthTokens(); Task> GetServerAuthToken(string clientIdentifier); Task UpsertServerAuthToken(PlexServerAuthToken serverAuthToken); + Task DeleteAll(); } } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs index 87e650db1..ca7ae9364 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs @@ -16,5 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Plex PlexLibrary library, PlexConnection connection, PlexServerAuthToken token); + + Task> GetStatistics( + PlexMovie movie, + PlexConnection connection, + PlexServerAuthToken token); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs index 74f52762a..5e28eefb2 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs @@ -22,7 +22,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task Update(LocalMediaSource localMediaSource); Task Update(PlexMediaSource plexMediaSource); Task Update(PlexLibrary plexMediaSourceLibrary); - Task Delete(int id); + Task Delete(int mediaSourceId); + Task DeleteAllPlex(); Task DisablePlexLibrarySync(List libraryIds); Task EnablePlexLibrarySync(IEnumerable libraryIds); } diff --git a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs index cba58716b..0497f9dd3 100644 --- a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Plex; @@ -38,19 +39,26 @@ namespace ErsatzTV.Core.Plex await entries.Match( async movieEntries => { - foreach (PlexMovie entry in movieEntries) + foreach (PlexMovie incoming in movieEntries) { // TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper? // TODO: figure out how to rebuild playlists Either maybeMovie = await _movieRepository - .GetOrAdd(plexMediaSourceLibrary, entry) - .BindT(UpdateIfNeeded); - - maybeMovie.IfLeft( - error => _logger.LogWarning( - "Error processing plex movie at {Key}: {Error}", - entry.Key, - error.Value)); + .GetOrAdd(plexMediaSourceLibrary, incoming) + .BindT(existing => UpdateStatistics(existing, incoming, connection, token)) + .BindT(existing => UpdateMetadata(existing, incoming)) + .BindT(existing => UpdateArtwork(existing, incoming)); + + await maybeMovie.Match( + async movie => await _movieRepository.Update(movie), + error => + { + _logger.LogWarning( + "Error processing plex movie at {Key}: {Error}", + incoming.Key, + error.Value); + return Task.CompletedTask; + }); } }, error => @@ -68,10 +76,103 @@ namespace ErsatzTV.Core.Plex return Unit.Default; } - private Task> UpdateIfNeeded(PlexMovie plexMovie) => - // .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie)) - // .BindT(UpdateMetadata) - // .BindT(UpdatePoster); - Right(plexMovie).AsTask(); + private async Task> UpdateStatistics( + PlexMovie existing, + PlexMovie incoming, + PlexConnection connection, + PlexServerAuthToken token) + { + MediaVersion existingVersion = existing.MediaVersions.Head(); + MediaVersion incomingVersion = incoming.MediaVersions.Head(); + + if (incomingVersion.DateUpdated > existingVersion.DateUpdated || + string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio)) + { + Either maybeStatistics = + await _plexServerApiClient.GetStatistics(incoming, connection, token); + + maybeStatistics.IfRight( + mediaVersion => + { + existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1"; + existingVersion.VideoScanKind = mediaVersion.VideoScanKind; + existingVersion.DateUpdated = incomingVersion.DateUpdated; + }); + } + + return Right(existing); + } + + private Task> UpdateMetadata(PlexMovie existing, PlexMovie incoming) + { + MovieMetadata existingMetadata = existing.MovieMetadata.Head(); + MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); + + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + { + foreach (Genre genre in existingMetadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Remove(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Add(genre); + } + } + + return Right(existing).AsTask(); + } + + private Task> UpdateArtwork(PlexMovie existing, PlexMovie incoming) + { + MovieMetadata existingMetadata = existing.MovieMetadata.Head(); + MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); + + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + { + UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); + UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); + } + + return Right(existing).AsTask(); + } + + private void UpdateArtworkIfNeeded( + MovieMetadata existingMetadata, + MovieMetadata incomingMetadata, + ArtworkKind artworkKind) + { + Option maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); + + maybeIncomingArtwork.Match( + incomingArtwork => + { + Option maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); + + maybeExistingArtwork.Match( + existingArtwork => + { + existingArtwork.Path = incomingArtwork.Path; + existingArtwork.DateUpdated = incomingArtwork.DateUpdated; + }, + () => + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.Add(incomingArtwork); + }); + }, + () => + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); + }); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index bcdf3366c..b3eeba79e 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -160,14 +160,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await context.SaveChangesAsync(); } - public async Task Delete(int id) + public async Task Delete(int mediaSourceId) { await using TvContext context = _dbContextFactory.CreateDbContext(); - MediaSource mediaSource = await context.MediaSources.FindAsync(id); + MediaSource mediaSource = await context.MediaSources.FindAsync(mediaSourceId); context.MediaSources.Remove(mediaSource); await context.SaveChangesAsync(); } + public async Task DeleteAllPlex() + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + List allMediaSources = await context.PlexMediaSources.ToListAsync(); + context.PlexMediaSources.RemoveRange(allMediaSources); + await context.SaveChangesAsync(); + return Unit.Default; + } + public async Task DisablePlexLibrarySync(List libraryIds) { await _dbConnection.ExecuteAsync( diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index dd51f0d1b..d98feb9db 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -72,10 +72,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public async Task> GetOrAdd(PlexLibrary library, PlexMovie item) { - await using TvContext context = _dbContextFactory.CreateDbContext(); - Option maybeExisting = await context.PlexMovies - .AsNoTracking() + Option maybeExisting = await _dbContext.PlexMovies .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(i => i.MovieMetadata) + .ThenInclude(mm => mm.Artwork) .Include(i => i.MediaVersions) .ThenInclude(mv => mv.MediaFiles) .OrderBy(i => i.Key) @@ -83,7 +84,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return await maybeExisting.Match( plexMovie => Right(plexMovie).AsTask(), - async () => await AddPlexMovie(context, library, item)); + async () => await AddPlexMovie(_dbContext, library, item)); } public async Task Update(Movie movie) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs index b8d8500cf..e2aacefaf 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs @@ -61,8 +61,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public Task> GetNextItemStart(int channelId, DateTimeOffset now) => _dbContext.PlayoutItems .Where(pi => pi.Playout.ChannelId == channelId) - .Where(pi => pi.Finish > now.UtcDateTime) - .OrderBy(pi => pi.Finish) + .Where(pi => pi.Start > now.UtcDateTime) + .OrderBy(pi => pi.Start) .FirstOrDefaultAsync() .Map(Optional) .MapT(pi => pi.StartOffset); diff --git a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs index a0e528bb8..4e3463d17 100644 --- a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs +++ b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs @@ -7,11 +7,14 @@ namespace ErsatzTV.Infrastructure.Locking public class EntityLocker : IEntityLocker { private readonly ConcurrentDictionary _lockedMediaSources; + private bool _plex; public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary(); public event EventHandler OnLibraryChanged; + public event EventHandler OnPlexChanged; + public bool LockLibrary(int mediaSourceId) { if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0)) @@ -36,5 +39,31 @@ namespace ErsatzTV.Infrastructure.Locking public bool IsLibraryLocked(int mediaSourceId) => _lockedMediaSources.ContainsKey(mediaSourceId); + + public bool LockPlex() + { + if (!_plex) + { + _plex = true; + OnPlexChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + return false; + } + + public bool UnlockPlex() + { + if (_plex) + { + _plex = false; + OnPlexChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + return false; + } + + public bool IsPlexLocked() => _plex; } } diff --git a/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs index 075c8fb5a..2ae419f40 100644 --- a/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs +++ b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs @@ -18,5 +18,12 @@ namespace ErsatzTV.Infrastructure.Plex string key, [Query] [AliasAs("X-Plex-Token")] string token); + + [Get("/library/metadata/{key}")] + public Task>> + GetMetadata( + string key, + [Query] [AliasAs("X-Plex-Token")] + string token); } } diff --git a/ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs b/ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs index 5669041f8..84503e450 100644 --- a/ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs +++ b/ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs @@ -36,6 +36,8 @@ namespace ErsatzTV.Infrastructure.Plex [Get("/resources")] Task> GetResources( + [Query] [AliasAs("includeHttps")] + int includeHttps, [Header("X-Plex-Client-Identifier")] string clientIdentifier, [Header("X-Plex-Token")] diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexGenreResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexGenreResponse.cs new file mode 100644 index 000000000..e06e57bad --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexGenreResponse.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexGenreResponse + { + public int Id { get; set; } + public string Filter { get; set; } + public string Tag { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs index 293505e36..772e7eea7 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs @@ -15,5 +15,6 @@ namespace ErsatzTV.Infrastructure.Plex.Models public int AddedAt { get; set; } public int UpdatedAt { get; set; } public List Media { get; set; } + public List Genre { get; set; } } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs index c8a09a21d..3cf46d1d9 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs @@ -1,4 +1,6 @@ -namespace ErsatzTV.Infrastructure.Plex.Models +using System.Collections.Generic; + +namespace ErsatzTV.Infrastructure.Plex.Models { public class PlexPartResponse { @@ -6,5 +8,6 @@ public string Key { get; set; } public int Duration { get; set; } public string File { get; set; } + public List Stream { get; set; } } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs index afe65750f..e02c061ca 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs @@ -12,6 +12,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models public bool Owned { get; set; } public string Provides { get; set; } + public bool HttpsRequired { get; set; } public List Connections { get; set; } } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs new file mode 100644 index 000000000..a3b2d31ff --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Infrastructure.Plex.Models +{ + public class PlexStreamResponse + { + public int Id { get; set; } + public int StreamType { get; set; } + public bool Anamorphic { get; set; } + public string PixelAspectRatio { get; set; } + public string ScanType { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs b/ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs index e811f5c10..6f1a156cc 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs @@ -32,12 +32,6 @@ namespace ErsatzTV.Infrastructure.Plex tokens => tokens.Map(kvp => new PlexUserAuthToken(kvp.Key, kvp.Value)).ToList(), () => new List())); - public Task> GetServerAuthTokens() => - ReadSecrets().Map( - s => Optional(s.ServerAuthTokens).Match( - tokens => tokens.Map(kvp => new PlexServerAuthToken(kvp.Key, kvp.Value)).ToList(), - () => new List())); - public Task> GetServerAuthToken(string clientIdentifier) => ReadSecrets().Map( s => Optional(s.ServerAuthTokens.SingleOrDefault(kvp => kvp.Key == clientIdentifier)) @@ -61,6 +55,9 @@ namespace ErsatzTV.Infrastructure.Plex return SaveSecrets(secrets); }); + public Task DeleteAll() => + ReadSecrets().Bind(secrets => SaveSecrets(new PlexSecrets { ClientIdentifier = secrets.ClientIdentifier })); + private static Task ReadSecrets() => File.ReadAllTextAsync(FileSystemLayout.PlexSecretsPath) .Map(JsonConvert.DeserializeObject) diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index 89d6f3a31..f342ecdc7 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -60,6 +60,27 @@ namespace ErsatzTV.Infrastructure.Plex } } + public async Task> GetStatistics( + PlexMovie movie, + PlexConnection connection, + PlexServerAuthToken token) + { + try + { + IPlexServerApi service = RestService.For(connection.Uri); + return await service.GetMetadata(movie.Key.Split("/").Last(), token.AuthToken) + .Map( + r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0) + .HeadOrNone()) + .BindT(ProjectToMediaVersion) + .Map(o => o.ToEither("Unable to locate metadata")); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + private static Option Project(PlexLibraryResponse response) => response.Type switch { @@ -99,7 +120,8 @@ namespace ErsatzTV.Infrastructure.Plex Year = response.Year, Tagline = response.Tagline, DateAdded = dateAdded, - DateUpdated = lastWriteTime + DateUpdated = lastWriteTime, + Genres = response.Genre.Map(g => new Genre { Name = g.Tag }).ToList() }; if (!string.IsNullOrWhiteSpace(response.Thumb)) @@ -117,6 +139,21 @@ namespace ErsatzTV.Infrastructure.Plex metadata.Artwork.Add(artwork); } + if (!string.IsNullOrWhiteSpace(response.Art)) + { + var path = $"plex/{mediaSourceId}{response.Art}"; + var artwork = new Artwork + { + ArtworkKind = ArtworkKind.FanArt, + Path = path, + DateAdded = dateAdded, + DateUpdated = lastWriteTime + }; + + metadata.Artwork ??= new List(); + metadata.Artwork.Add(artwork); + } + var version = new MediaVersion { Name = "Main", @@ -126,7 +163,8 @@ namespace ErsatzTV.Infrastructure.Plex AudioCodec = media.AudioCodec, VideoCodec = media.VideoCodec, VideoProfile = media.VideoProfile, - SampleAspectRatio = ConvertToSAR(media.AspectRatio), + // specifically omit sample aspect ratio + DateUpdated = lastWriteTime, MediaFiles = new List { new PlexMediaFile @@ -148,8 +186,21 @@ namespace ErsatzTV.Infrastructure.Plex return movie; } - private static string ConvertToSAR(double aspectRatio) => "1:1"; - // TODO: fix this with more detailed stats pull from plex for each item - // Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100"; + private Option ProjectToMediaVersion(PlexMetadataResponse response) + { + Option maybeStream = + response.Media.Head().Part.Head().Stream.Find(s => s.StreamType == 1); + return maybeStream.Map( + stream => new MediaVersion + { + SampleAspectRatio = stream.PixelAspectRatio, + VideoScanKind = stream.ScanType switch + { + "interlaced" => VideoScanKind.Interlaced, + "progressive" => VideoScanKind.Progressive, + _ => VideoScanKind.Unknown + } + }); + } } } diff --git a/ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs index 7f5f8ec1e..e889a1fe9 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs @@ -34,8 +34,22 @@ namespace ErsatzTV.Infrastructure.Plex string clientIdentifier = await _plexSecretStore.GetClientIdentifier(); foreach (PlexUserAuthToken token in await _plexSecretStore.GetUserAuthTokens()) { - List resources = await _plexTvApi.GetResources(clientIdentifier, token.AuthToken); - IEnumerable sources = resources + List httpResources = await _plexTvApi.GetResources( + 0, + clientIdentifier, + token.AuthToken); + + List httpsResources = await _plexTvApi.GetResources( + 1, + clientIdentifier, + token.AuthToken); + + + var allResources = httpResources.Filter(resource => resource.HttpsRequired == false) + .Append(httpsResources.Filter(resource => resource.HttpsRequired)) + .ToList(); + + IEnumerable sources = allResources .Filter(r => r.Provides.Split(",").Any(p => p == "server")) .Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future .Map( @@ -46,13 +60,16 @@ namespace ErsatzTV.Infrastructure.Plex resource.AccessToken); _plexSecretStore.UpsertServerAuthToken(serverAuthToken); + List sortedConnections = resource.HttpsRequired + ? resource.Connections + : resource.Connections.OrderBy(c => c.Local ? 0 : 1).ToList(); var source = new PlexMediaSource { ServerName = resource.Name, ProductVersion = resource.ProductVersion, ClientIdentifier = resource.ClientIdentifier, - Connections = resource.Connections + Connections = sortedConnections .Map(c => new PlexConnection { Uri = c.Uri }).ToList() }; diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index e26ef4c51..43b778694 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -74,6 +74,32 @@ namespace ErsatzTV.Controllers }); } + [HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")] + public async Task GetPlexFanArt(int plexMediaSourceId, string path) + { + Either connectionParameters = + await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId)); + + return await connectionParameters.Match>( + Left: _ => new NotFoundResult().AsTask(), + Right: async r => + { + HttpClient client = _httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken); + + var transcodePath = $"/{path}"; + var fullPath = new Uri(r.Uri, transcodePath); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead); + Stream stream = await response.Content.ReadAsStreamAsync(); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + }); + } + [HttpGet("/artwork/thumbnails/{fileName}")] public async Task GetThumbnail(string fileName) { diff --git a/ErsatzTV/Pages/PlexLibrariesEditor.razor b/ErsatzTV/Pages/PlexLibrariesEditor.razor index e2395809d..9eb23f7e1 100644 --- a/ErsatzTV/Pages/PlexLibrariesEditor.razor +++ b/ErsatzTV/Pages/PlexLibrariesEditor.razor @@ -9,7 +9,7 @@ @inject ChannelWriter Channel @inject IEntityLocker Locker - + @_source.Name Libraries @@ -28,16 +28,7 @@ @context.Name @context.MediaKind -
- @if (context.ShouldSyncItems) - { - Yes - } - else - { - No - } -
+
diff --git a/ErsatzTV/Pages/PlexMediaSources.razor b/ErsatzTV/Pages/PlexMediaSources.razor index 2c79cba0b..d5de8cf3d 100644 --- a/ErsatzTV/Pages/PlexMediaSources.razor +++ b/ErsatzTV/Pages/PlexMediaSources.razor @@ -2,13 +2,15 @@ @using ErsatzTV.Application.Plex @using ErsatzTV.Application.Plex.Commands @using ErsatzTV.Application.Plex.Queries +@implements IDisposable @inject IDialogService Dialog @inject IMediator Mediator +@inject IEntityLocker Locker @inject ISnackbar Snackbar @inject ILogger Logger @inject IJSRuntime JsRuntime - + Plex Media Sources @@ -42,9 +44,26 @@ - @* *@ - @* Add Plex Media Source *@ - @* *@ + @if (_mediaSources.Any()) + { + + Sign out of plex + + } + else + { + + Sign in to plex + + } @code { @@ -52,46 +71,50 @@ protected override async Task OnParametersSetAsync() => await LoadMediaSources(); + protected override void OnInitialized() => + Locker.OnPlexChanged += PlexChanged; + private async Task LoadMediaSources() => _mediaSources = await Mediator.Send(new GetAllPlexMediaSources()); - // private async Task DeleteMediaSource(PlexMediaSourceViewModel mediaSource) - // { - // int count = await Mediator.Send(new CountMediaItemsById(mediaSource.Id)); - // - // var parameters = new DialogParameters - // { - // { "EntityType", "media source" }, - // { "EntityName", mediaSource.Name }, - // { "DetailText", $"This media source contains {count} media items." }, - // { "DetailHighlight", count.ToString() } - // }; - // var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; - // - // IDialogReference dialog = Dialog.Show("Delete Media Source", parameters, options); - // DialogResult result = await dialog.Result; - // if (!result.Cancelled) - // { - // await Mediator.Send(new DeleteLocalMediaSource(mediaSource.Id)); - // await LoadMediaSources(); - // } - // } - - // edit media source - // - manage libraries to include in smart collections - // - manage list of path replacements + private async Task SignOutOfPlex() + { + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }; + IDialogReference dialog = Dialog.Show("Sign out of Plex", options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + if (Locker.LockPlex()) + { + await Mediator.Send(new SignOutOfPlex()); + await LoadMediaSources(); + } + } + } private async Task AddPlexMediaSource() { - Either maybeUrl = await Mediator.Send(new StartPlexPinFlow()); - await maybeUrl.Match( - async url => await JsRuntime.InvokeAsync("open", new object[] { url, "_blank" }), - error => - { - Snackbar.Add(error.Value, Severity.Error); - Logger.LogError("Unexpected error generating plex auth app url: {Error}", error.Value); - return Task.CompletedTask; - }); + if (Locker.LockPlex()) + { + Either maybeUrl = await Mediator.Send(new StartPlexPinFlow()); + await maybeUrl.Match( + async url => await JsRuntime.InvokeAsync("open", new object[] { url, "_blank" }), + error => + { + Locker.UnlockPlex(); + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Unexpected error generating plex auth app url: {Error}", error.Value); + return Task.CompletedTask; + }); + } } + private void PlexChanged(object sender, EventArgs e) + { + InvokeAsync(LoadMediaSources); + InvokeAsync(StateHasChanged); + } + + void IDisposable.Dispose() => Locker.OnPlexChanged -= PlexChanged; + } \ No newline at end of file diff --git a/ErsatzTV/Pages/PlexPathReplacementsEditor.razor b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor index 325471ae6..2345408de 100644 --- a/ErsatzTV/Pages/PlexPathReplacementsEditor.razor +++ b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor @@ -7,7 +7,7 @@ @inject ISnackbar Snackbar @inject IMediator Mediator - + @_source.Name Path Replacements diff --git a/ErsatzTV/Services/PlexService.cs b/ErsatzTV/Services/PlexService.cs index afbd61999..c137ac359 100644 --- a/ErsatzTV/Services/PlexService.cs +++ b/ErsatzTV/Services/PlexService.cs @@ -46,13 +46,7 @@ namespace ErsatzTV.Services FileSystemLayout.PlexSecretsPath); // synchronize sources on startup - List sources = await SynchronizeSources( - new SynchronizePlexMediaSources(), - cancellationToken); - foreach (PlexMediaSource source in sources) - { - await SynchronizeLibraries(new SynchronizePlexLibraries(source.Id), cancellationToken); - } + await SynchronizeSources(new SynchronizePlexMediaSources(), cancellationToken); await foreach (IPlexBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken)) { @@ -64,6 +58,9 @@ namespace ErsatzTV.Services SynchronizePlexMediaSources sourcesRequest => SynchronizeSources( sourcesRequest, cancellationToken), + SynchronizePlexLibraries synchronizePlexLibrariesRequest => SynchronizeLibraries( + synchronizePlexLibrariesRequest, + cancellationToken), _ => throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}") }; diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index fe28e196b..f37ba1588 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -38,9 +38,9 @@ Channels FFmpeg - @* *@ - @* Plex *@ - @* *@ + + Plex + Libraries TV Shows diff --git a/ErsatzTV/Shared/SignOutOfPlexDialog.razor b/ErsatzTV/Shared/SignOutOfPlexDialog.razor new file mode 100644 index 000000000..71300ec9b --- /dev/null +++ b/ErsatzTV/Shared/SignOutOfPlexDialog.razor @@ -0,0 +1,24 @@ + + + + + + + + Cancel + Sign out + + + +@code { + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); + +} \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 325e12d86..16ed35567 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -208,7 +208,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); - // services.AddHostedService(); + services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService();