Browse Source

enable plex for movies (#72)

* re-enable plex, temp force secure connections

* add plex fanart

* synchronize genre from plex

* fix plex library sync

* improve stream error handling

* synchronize plex artwork

* use switch instead of button

* prioritize local connections for insecure plex sources

* sign out of plex

* better plex sign in/out

* code cleanup

* fix plex movie aspect ratio and scan type
pull/74/head
Jason Dove 5 years ago committed by GitHub
parent
commit
aa938baec8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      ErsatzTV.Application/Plex/Commands/SignOutOfPlex.cs
  2. 36
      ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs
  3. 3
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs
  4. 17
      ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs
  5. 139
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  6. 9
      ErsatzTV.Core/Errors/PlayoutItemDoesNotExistOnDisk.cs
  7. 9
      ErsatzTV.Core/Errors/UnableToLocatePlayoutItem.cs
  8. 4
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  9. 4
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  10. 2
      ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs
  11. 5
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  12. 3
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  13. 129
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  14. 13
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  15. 9
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  16. 4
      ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs
  17. 29
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  18. 7
      ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs
  19. 2
      ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs
  20. 9
      ErsatzTV.Infrastructure/Plex/Models/PlexGenreResponse.cs
  21. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  22. 5
      ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs
  23. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs
  24. 11
      ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs
  25. 9
      ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs
  26. 61
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  27. 23
      ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs
  28. 26
      ErsatzTV/Controllers/ArtworkController.cs
  29. 13
      ErsatzTV/Pages/PlexLibrariesEditor.razor
  30. 99
      ErsatzTV/Pages/PlexMediaSources.razor
  31. 2
      ErsatzTV/Pages/PlexPathReplacementsEditor.razor
  32. 11
      ErsatzTV/Services/PlexService.cs
  33. 6
      ErsatzTV/Shared/MainLayout.razor
  34. 24
      ErsatzTV/Shared/SignOutOfPlexDialog.razor
  35. 2
      ErsatzTV/Startup.cs

7
ErsatzTV.Application/Plex/Commands/SignOutOfPlex.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public record SignOutOfPlex : MediatR.IRequest<Either<BaseError, Unit>>;
}

36
ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs

@ -0,0 +1,36 @@ @@ -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<SignOutOfPlex, Either<BaseError, Unit>>
{
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<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
{
await _mediaSourceRepository.DeleteAllPlex();
await _plexSecretStore.DeleteAll();
_entityLocker.UnlockPlex();
return Unit.Default;
}
}
}

3
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs

@ -3,5 +3,6 @@ using LanguageExt; @@ -3,5 +3,6 @@ using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>;
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>,
IPlexBackgroundServiceRequest;
}

17
ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs

@ -1,9 +1,11 @@ @@ -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 @@ -15,15 +17,21 @@ namespace ErsatzTV.Application.Plex.Commands
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
Either<BaseError, List<PlexMediaSource>>>
{
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexTvApiClient _plexTvApiClient;
public SynchronizePlexMediaSourcesHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexTvApiClient plexTvApiClient)
IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
{
_mediaSourceRepository = mediaSourceRepository;
_plexTvApiClient = plexTvApiClient;
_channel = channel;
_entityLocker = entityLocker;
}
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
@ -39,6 +47,13 @@ namespace ErsatzTV.Application.Plex.Commands @@ -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;
}

139
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -6,10 +6,13 @@ using System.Linq; @@ -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 @@ -17,6 +20,7 @@ namespace ErsatzTV.Application.Streaming.Queries
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlayoutRepository _playoutRepository;
@ -27,12 +31,14 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -27,12 +31,14 @@ namespace ErsatzTV.Application.Streaming.Queries
IPlayoutRepository playoutRepository,
IMediaSourceRepository mediaSourceRepository,
FFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(channelRepository, configElementRepository)
{
_playoutRepository = playoutRepository;
_mediaSourceRepository = mediaSourceRepository;
_ffmpegProcessService = ffmpegProcessService;
_localFileSystem = localFileSystem;
_logger = logger;
}
@ -42,49 +48,124 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -42,49 +48,124 @@ namespace ErsatzTV.Application.Streaming.Queries
string ffmpegPath)
{
DateTimeOffset now = DateTimeOffset.Now;
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
return await maybePlayoutItem.Match<Task<Either<BaseError, Process>>>(
async playoutItem =>
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await _playoutRepository
.GetPlayoutItem(channel.Id, now)
.Map(o => o.ToEither<BaseError>(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<BaseError, Process>(
_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<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Filter(transcode => transcode)
.Match(
_ => _playoutRepository.GetNextItemStart(channel.Id, now)
.MapT(nextStart => nextStart - now),
() => Option<TimeSpan>.None.AsTask());
switch (error)
{
Option<TimeSpan> 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<Either<BaseError, PlayoutItemWithPath>> 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<string> 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<string> GetReplacementPlexPath(int libraryPathId, string path)
{
List<PlexPathReplacement> replacements =
@ -105,5 +186,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -105,5 +186,7 @@ namespace ErsatzTV.Application.Streaming.Queries
},
() => path);
}
private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
}
}

9
ErsatzTV.Core/Errors/PlayoutItemDoesNotExistOnDisk.cs

@ -0,0 +1,9 @@ @@ -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}")
{
}
}
}

9
ErsatzTV.Core/Errors/UnableToLocatePlayoutItem.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors
{
public class UnableToLocatePlayoutItem : BaseError
{
public UnableToLocatePlayoutItem() : base("Unable to locate playout item")
{
}
}
}

4
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -84,7 +84,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -84,7 +84,7 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process ForOfflineImage(string ffmpegPath, Channel channel, Option<TimeSpan> duration)
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
{
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@ -99,7 +99,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -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)

4
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -5,8 +5,12 @@ namespace ErsatzTV.Core.Interfaces.Locking @@ -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();
}
}

2
ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs

@ -10,8 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Plex @@ -10,8 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Plex
Task<string> GetClientIdentifier();
Task<List<PlexUserAuthToken>> GetUserAuthTokens();
Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
Task<List<PlexServerAuthToken>> GetServerAuthTokens();
Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);
Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken);
Task<Unit> DeleteAll();
}
}

5
ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs

@ -16,5 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Plex @@ -16,5 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Plex
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, MediaVersion>> GetStatistics(
PlexMovie movie,
PlexConnection connection,
PlexServerAuthToken token);
}
}

3
ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs

@ -22,7 +22,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -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<Unit> DeleteAllPlex();
Task DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
}

129
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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<BaseError, PlexMovie> 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 @@ -68,10 +76,103 @@ namespace ErsatzTV.Core.Plex
return Unit.Default;
}
private Task<Either<BaseError, PlexMovie>> UpdateIfNeeded(PlexMovie plexMovie) =>
// .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
// .BindT(UpdateMetadata)
// .BindT(UpdatePoster);
Right<BaseError, PlexMovie>(plexMovie).AsTask();
private async Task<Either<BaseError, PlexMovie>> 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<BaseError, MediaVersion> 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<BaseError, PlexMovie>(existing);
}
private Task<Either<BaseError, PlexMovie>> 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<BaseError, PlexMovie>(existing).AsTask();
}
private Task<Either<BaseError, PlexMovie>> 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<BaseError, PlexMovie>(existing).AsTask();
}
private void UpdateArtworkIfNeeded(
MovieMetadata existingMetadata,
MovieMetadata incomingMetadata,
ArtworkKind artworkKind)
{
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
maybeIncomingArtwork.Match(
incomingArtwork =>
{
Option<Artwork> 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<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
});
},
() =>
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
});
}
}
}

13
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -160,14 +160,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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<Unit> DeleteAllPlex()
{
await using TvContext context = _dbContextFactory.CreateDbContext();
List<PlexMediaSource> allMediaSources = await context.PlexMediaSources.ToListAsync();
context.PlexMediaSources.RemoveRange(allMediaSources);
await context.SaveChangesAsync();
return Unit.Default;
}
public async Task DisablePlexLibrarySync(List<int> libraryIds)
{
await _dbConnection.ExecuteAsync(

9
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -72,10 +72,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -72,10 +72,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexMovie> maybeExisting = await context.PlexMovies
.AsNoTracking()
Option<PlexMovie> 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 @@ -83,7 +84,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return await maybeExisting.Match(
plexMovie => Right<BaseError, PlexMovie>(plexMovie).AsTask(),
async () => await AddPlexMovie(context, library, item));
async () => await AddPlexMovie(_dbContext, library, item));
}
public async Task<bool> Update(Movie movie)

4
ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs

@ -61,8 +61,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -61,8 +61,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<DateTimeOffset>> 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);

29
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -7,11 +7,14 @@ namespace ErsatzTV.Infrastructure.Locking @@ -7,11 +7,14 @@ namespace ErsatzTV.Infrastructure.Locking
public class EntityLocker : IEntityLocker
{
private readonly ConcurrentDictionary<int, byte> _lockedMediaSources;
private bool _plex;
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
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 @@ -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;
}
}

7
ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs

@ -18,5 +18,12 @@ namespace ErsatzTV.Infrastructure.Plex @@ -18,5 +18,12 @@ namespace ErsatzTV.Infrastructure.Plex
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetMetadata(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
}
}

2
ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs

@ -36,6 +36,8 @@ namespace ErsatzTV.Infrastructure.Plex @@ -36,6 +36,8 @@ namespace ErsatzTV.Infrastructure.Plex
[Get("/resources")]
Task<List<PlexResource>> GetResources(
[Query] [AliasAs("includeHttps")]
int includeHttps,
[Header("X-Plex-Client-Identifier")]
string clientIdentifier,
[Header("X-Plex-Token")]

9
ErsatzTV.Infrastructure/Plex/Models/PlexGenreResponse.cs

@ -0,0 +1,9 @@ @@ -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; }
}
}

1
ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs

@ -15,5 +15,6 @@ namespace ErsatzTV.Infrastructure.Plex.Models @@ -15,5 +15,6 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public List<PlexMediaResponse> Media { get; set; }
public List<PlexGenreResponse> Genre { get; set; }
}
}

5
ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs

@ -1,4 +1,6 @@ @@ -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 @@ @@ -6,5 +8,6 @@
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public List<PlexStreamResponse> Stream { get; set; }
}
}

1
ErsatzTV.Infrastructure/Plex/Models/PlexResource.cs

@ -12,6 +12,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models @@ -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<PlexResourceConnection> Connections { get; set; }
}
}

11
ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs

@ -0,0 +1,11 @@ @@ -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; }
}
}

9
ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs

@ -32,12 +32,6 @@ namespace ErsatzTV.Infrastructure.Plex @@ -32,12 +32,6 @@ namespace ErsatzTV.Infrastructure.Plex
tokens => tokens.Map(kvp => new PlexUserAuthToken(kvp.Key, kvp.Value)).ToList(),
() => new List<PlexUserAuthToken>()));
public Task<List<PlexServerAuthToken>> GetServerAuthTokens() =>
ReadSecrets().Map(
s => Optional(s.ServerAuthTokens).Match(
tokens => tokens.Map(kvp => new PlexServerAuthToken(kvp.Key, kvp.Value)).ToList(),
() => new List<PlexServerAuthToken>()));
public Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier) =>
ReadSecrets().Map(
s => Optional(s.ServerAuthTokens.SingleOrDefault(kvp => kvp.Key == clientIdentifier))
@ -61,6 +55,9 @@ namespace ErsatzTV.Infrastructure.Plex @@ -61,6 +55,9 @@ namespace ErsatzTV.Infrastructure.Plex
return SaveSecrets(secrets);
});
public Task<Unit> DeleteAll() =>
ReadSecrets().Bind(secrets => SaveSecrets(new PlexSecrets { ClientIdentifier = secrets.ClientIdentifier }));
private static Task<PlexSecrets> ReadSecrets() =>
File.ReadAllTextAsync(FileSystemLayout.PlexSecretsPath)
.Map(JsonConvert.DeserializeObject<PlexSecrets>)

61
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -60,6 +60,27 @@ namespace ErsatzTV.Infrastructure.Plex @@ -60,6 +60,27 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, MediaVersion>> GetStatistics(
PlexMovie movie,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(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<BaseError>("Unable to locate metadata"));
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private static Option<PlexLibrary> Project(PlexLibraryResponse response) =>
response.Type switch
{
@ -99,7 +120,8 @@ namespace ErsatzTV.Infrastructure.Plex @@ -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 @@ -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<Artwork>();
metadata.Artwork.Add(artwork);
}
var version = new MediaVersion
{
Name = "Main",
@ -126,7 +163,8 @@ namespace ErsatzTV.Infrastructure.Plex @@ -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<MediaFile>
{
new PlexMediaFile
@ -148,8 +186,21 @@ namespace ErsatzTV.Infrastructure.Plex @@ -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<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
{
Option<PlexStreamResponse> 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
}
});
}
}
}

23
ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs

@ -34,8 +34,22 @@ namespace ErsatzTV.Infrastructure.Plex @@ -34,8 +34,22 @@ namespace ErsatzTV.Infrastructure.Plex
string clientIdentifier = await _plexSecretStore.GetClientIdentifier();
foreach (PlexUserAuthToken token in await _plexSecretStore.GetUserAuthTokens())
{
List<PlexResource> resources = await _plexTvApi.GetResources(clientIdentifier, token.AuthToken);
IEnumerable<PlexMediaSource> sources = resources
List<PlexResource> httpResources = await _plexTvApi.GetResources(
0,
clientIdentifier,
token.AuthToken);
List<PlexResource> httpsResources = await _plexTvApi.GetResources(
1,
clientIdentifier,
token.AuthToken);
var allResources = httpResources.Filter(resource => resource.HttpsRequired == false)
.Append(httpsResources.Filter(resource => resource.HttpsRequired))
.ToList();
IEnumerable<PlexMediaSource> 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 @@ -46,13 +60,16 @@ namespace ErsatzTV.Infrastructure.Plex
resource.AccessToken);
_plexSecretStore.UpsertServerAuthToken(serverAuthToken);
List<PlexResourceConnection> 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()
};

26
ErsatzTV/Controllers/ArtworkController.cs

@ -74,6 +74,32 @@ namespace ErsatzTV.Controllers @@ -74,6 +74,32 @@ namespace ErsatzTV.Controllers
});
}
[HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")]
public async Task<IActionResult> GetPlexFanArt(int plexMediaSourceId, string path)
{
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId));
return await connectionParameters.Match<Task<IActionResult>>(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
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<IActionResult> GetThumbnail(string fileName)
{

13
ErsatzTV/Pages/PlexLibrariesEditor.razor

@ -9,7 +9,7 @@ @@ -9,7 +9,7 @@
@inject ChannelWriter<IBackgroundServiceRequest> Channel
@inject IEntityLocker Locker
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_libraries" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6"><b>@_source.Name</b> Libraries</MudText>
@ -28,16 +28,7 @@ @@ -28,16 +28,7 @@
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="MediaType">@context.MediaKind</MudTd>
<MudTd DataLabel="Synchronize">
<div style="display: flex; justify-content: center">
@if (context.ShouldSyncItems)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => context.ShouldSyncItems = !context.ShouldSyncItems)">Yes</MudButton>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Dark" OnClick="@(() => context.ShouldSyncItems = !context.ShouldSyncItems)">No</MudButton>
}
</div>
<MudSwitch T="bool" @bind-Checked="@context.ShouldSyncItems" Color="Color.Primary"/>
</MudTd>
</RowTemplate>
</MudTable>

99
ErsatzTV/Pages/PlexMediaSources.razor

@ -2,13 +2,15 @@ @@ -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<PlexMediaSources> Logger
@inject IJSRuntime JsRuntime
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Dense="true" Items="_mediaSources">
<ToolBarContent>
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
@ -42,9 +44,26 @@ @@ -42,9 +44,26 @@
</MudTd>
</RowTemplate>
</MudTable>
@* <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlexMediaSource())" Class="mt-4"> *@
@* Add Plex Media Source *@
@* </MudButton> *@
@if (_mediaSources.Any())
{
<MudButton Variant="Variant.Filled"
Color="Color.Error"
OnClick="@(_ => SignOutOfPlex())"
Disabled="@Locker.IsPlexLocked()"
Class="mt-4">
Sign out of plex
</MudButton>
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="@(_ => AddPlexMediaSource())"
Disabled="@Locker.IsPlexLocked()"
Class="mt-4">
Sign in to plex
</MudButton>
}
</MudContainer>
@code {
@ -52,46 +71,50 @@ @@ -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<DeleteDialog>("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<SignOutOfPlexDialog>("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<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow());
await maybeUrl.Match(
async url => await JsRuntime.InvokeAsync<object>("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<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow());
await maybeUrl.Match(
async url => await JsRuntime.InvokeAsync<object>("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;
}

2
ErsatzTV/Pages/PlexPathReplacementsEditor.razor

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_pathReplacements.OrderBy(r => r.Id)" Dense="true" @bind-SelectedItem="_selectedItem">
<ToolBarContent>
<MudText Typo="Typo.h6"><b>@_source.Name</b> Path Replacements</MudText>

11
ErsatzTV/Services/PlexService.cs

@ -46,13 +46,7 @@ namespace ErsatzTV.Services @@ -46,13 +46,7 @@ namespace ErsatzTV.Services
FileSystemLayout.PlexSecretsPath);
// synchronize sources on startup
List<PlexMediaSource> 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 @@ -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}")
};

6
ErsatzTV/Shared/MainLayout.razor

@ -38,9 +38,9 @@ @@ -38,9 +38,9 @@
<MudNavMenu>
<MudNavLink Href="/channels">Channels</MudNavLink>
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
@* <MudNavGroup Title="Media Sources" Expanded="true"> *@
@* <MudNavLink Href="/media/plex">Plex</MudNavLink> *@
@* </MudNavGroup> *@
<MudNavGroup Title="Media Sources" Expanded="true">
<MudNavLink Href="/media/plex">Plex</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>

24
ErsatzTV/Shared/SignOutOfPlexDialog.razor

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
<MudDialog>
<DialogContent>
<MudContainer>
<MudHighlighter Class="mud-primary-text"
Style="background-color: transparent; font-weight: bold"
Text="Do you really want to sign out of Plex? All synchronized content will be removed."/>
</MudContainer>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Submit">Sign out</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
MudDialogInstance MudDialog { get; set; }
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
}

2
ErsatzTV/Startup.cs

@ -208,7 +208,7 @@ namespace ErsatzTV @@ -208,7 +208,7 @@ namespace ErsatzTV
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
// services.AddHostedService<PlexService>();
services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();
services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>();

Loading…
Cancel
Save