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
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Plex.Commands
|
||||||
|
{
|
||||||
|
public record SignOutOfPlex : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||||
|
}
|
||||||
@@ -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,5 +3,6 @@ using LanguageExt;
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Plex.Commands
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Core.Interfaces.Plex;
|
using ErsatzTV.Core.Interfaces.Plex;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
@@ -15,15 +17,21 @@ namespace ErsatzTV.Application.Plex.Commands
|
|||||||
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
|
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
|
||||||
Either<BaseError, List<PlexMediaSource>>>
|
Either<BaseError, List<PlexMediaSource>>>
|
||||||
{
|
{
|
||||||
|
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
|
||||||
|
private readonly IEntityLocker _entityLocker;
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
private readonly IPlexTvApiClient _plexTvApiClient;
|
private readonly IPlexTvApiClient _plexTvApiClient;
|
||||||
|
|
||||||
public SynchronizePlexMediaSourcesHandler(
|
public SynchronizePlexMediaSourcesHandler(
|
||||||
IMediaSourceRepository mediaSourceRepository,
|
IMediaSourceRepository mediaSourceRepository,
|
||||||
IPlexTvApiClient plexTvApiClient)
|
IPlexTvApiClient plexTvApiClient,
|
||||||
|
ChannelWriter<IPlexBackgroundServiceRequest> channel,
|
||||||
|
IEntityLocker entityLocker)
|
||||||
{
|
{
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
_mediaSourceRepository = mediaSourceRepository;
|
||||||
_plexTvApiClient = plexTvApiClient;
|
_plexTvApiClient = plexTvApiClient;
|
||||||
|
_channel = channel;
|
||||||
|
_entityLocker = entityLocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
|
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
|
||||||
@@ -39,6 +47,13 @@ namespace ErsatzTV.Application.Plex.Commands
|
|||||||
await SynchronizeServer(allExisting, server);
|
await SynchronizeServer(allExisting, server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (PlexMediaSource mediaSource in await _mediaSourceRepository.GetAllPlex())
|
||||||
|
{
|
||||||
|
await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
_entityLocker.UnlockPlex();
|
||||||
|
|
||||||
return allExisting;
|
return allExisting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
-29
@@ -6,10 +6,13 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.FFmpeg;
|
using ErsatzTV.Core.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Streaming.Queries
|
namespace ErsatzTV.Application.Streaming.Queries
|
||||||
{
|
{
|
||||||
@@ -17,6 +20,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
||||||
{
|
{
|
||||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
private readonly IPlayoutRepository _playoutRepository;
|
private readonly IPlayoutRepository _playoutRepository;
|
||||||
@@ -27,12 +31,14 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
IPlayoutRepository playoutRepository,
|
IPlayoutRepository playoutRepository,
|
||||||
IMediaSourceRepository mediaSourceRepository,
|
IMediaSourceRepository mediaSourceRepository,
|
||||||
FFmpegProcessService ffmpegProcessService,
|
FFmpegProcessService ffmpegProcessService,
|
||||||
|
ILocalFileSystem localFileSystem,
|
||||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||||
: base(channelRepository, configElementRepository)
|
: base(channelRepository, configElementRepository)
|
||||||
{
|
{
|
||||||
_playoutRepository = playoutRepository;
|
_playoutRepository = playoutRepository;
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
_mediaSourceRepository = mediaSourceRepository;
|
||||||
_ffmpegProcessService = ffmpegProcessService;
|
_ffmpegProcessService = ffmpegProcessService;
|
||||||
|
_localFileSystem = localFileSystem;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,49 +48,124 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
string ffmpegPath)
|
string ffmpegPath)
|
||||||
{
|
{
|
||||||
DateTimeOffset now = DateTimeOffset.Now;
|
DateTimeOffset now = DateTimeOffset.Now;
|
||||||
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
|
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await _playoutRepository
|
||||||
return await maybePlayoutItem.Match<Task<Either<BaseError, Process>>>(
|
.GetPlayoutItem(channel.Id, now)
|
||||||
async playoutItem =>
|
.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(),
|
Movie m => m.MediaVersions.Head(),
|
||||||
Episode e => e.MediaVersions.Head(),
|
Episode e => e.MediaVersions.Head(),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
|
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
|
||||||
};
|
};
|
||||||
|
|
||||||
MediaFile file = version.MediaFiles.Head();
|
return Right<BaseError, Process>(
|
||||||
string path = file.Path;
|
_ffmpegProcessService.ForPlayoutItem(
|
||||||
if (playoutItem.MediaItem is PlexMovie plexMovie)
|
ffmpegPath,
|
||||||
{
|
channel,
|
||||||
path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path);
|
version,
|
||||||
}
|
playoutItemWithPath.Path,
|
||||||
|
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||||
return _ffmpegProcessService.ForPlayoutItem(
|
now)).AsTask();
|
||||||
ffmpegPath,
|
|
||||||
channel,
|
|
||||||
version,
|
|
||||||
path,
|
|
||||||
playoutItem.StartOffset,
|
|
||||||
now);
|
|
||||||
},
|
},
|
||||||
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)
|
case UnableToLocatePlayoutItem:
|
||||||
.MapT(nextStart => nextStart - now);
|
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}";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
|
||||||
{
|
{
|
||||||
List<PlexPathReplacement> replacements =
|
List<PlexPathReplacement> replacements =
|
||||||
@@ -105,5 +186,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
},
|
},
|
||||||
() => path);
|
() => path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ErsatzTV.Core.Errors
|
||||||
|
{
|
||||||
|
public class UnableToLocatePlayoutItem : BaseError
|
||||||
|
{
|
||||||
|
public UnableToLocatePlayoutItem() : base("Unable to locate playout item")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
.Build();
|
.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 =
|
FFmpegPlaybackSettings playbackSettings =
|
||||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||||
@@ -99,7 +99,7 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
.WithLoopedImage("Resources/background.png")
|
.WithLoopedImage("Resources/background.png")
|
||||||
.WithLibavfilter()
|
.WithLibavfilter()
|
||||||
.WithInput("anullsrc")
|
.WithInput("anullsrc")
|
||||||
.WithErrorText(desiredResolution, "Channel is Offline")
|
.WithErrorText(desiredResolution, errorMessage)
|
||||||
.WithPixfmt("yuv420p")
|
.WithPixfmt("yuv420p")
|
||||||
.WithPlaybackArgs(playbackSettings)
|
.WithPlaybackArgs(playbackSettings)
|
||||||
.WithMetadata(channel)
|
.WithMetadata(channel)
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ namespace ErsatzTV.Core.Interfaces.Locking
|
|||||||
public interface IEntityLocker
|
public interface IEntityLocker
|
||||||
{
|
{
|
||||||
event EventHandler OnLibraryChanged;
|
event EventHandler OnLibraryChanged;
|
||||||
|
event EventHandler OnPlexChanged;
|
||||||
bool LockLibrary(int libraryId);
|
bool LockLibrary(int libraryId);
|
||||||
bool UnlockLibrary(int libraryId);
|
bool UnlockLibrary(int libraryId);
|
||||||
bool IsLibraryLocked(int libraryId);
|
bool IsLibraryLocked(int libraryId);
|
||||||
|
bool LockPlex();
|
||||||
|
bool UnlockPlex();
|
||||||
|
bool IsPlexLocked();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
|||||||
Task<string> GetClientIdentifier();
|
Task<string> GetClientIdentifier();
|
||||||
Task<List<PlexUserAuthToken>> GetUserAuthTokens();
|
Task<List<PlexUserAuthToken>> GetUserAuthTokens();
|
||||||
Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
|
Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
|
||||||
Task<List<PlexServerAuthToken>> GetServerAuthTokens();
|
|
||||||
Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);
|
Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);
|
||||||
Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken);
|
Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken);
|
||||||
|
Task<Unit> DeleteAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
|||||||
PlexLibrary library,
|
PlexLibrary library,
|
||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token);
|
PlexServerAuthToken token);
|
||||||
|
|
||||||
|
Task<Either<BaseError, MediaVersion>> GetStatistics(
|
||||||
|
PlexMovie movie,
|
||||||
|
PlexConnection connection,
|
||||||
|
PlexServerAuthToken token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
Task Update(LocalMediaSource localMediaSource);
|
Task Update(LocalMediaSource localMediaSource);
|
||||||
Task Update(PlexMediaSource plexMediaSource);
|
Task Update(PlexMediaSource plexMediaSource);
|
||||||
Task Update(PlexLibrary plexMediaSourceLibrary);
|
Task Update(PlexLibrary plexMediaSourceLibrary);
|
||||||
Task Delete(int id);
|
Task Delete(int mediaSourceId);
|
||||||
|
Task<Unit> DeleteAllPlex();
|
||||||
Task DisablePlexLibrarySync(List<int> libraryIds);
|
Task DisablePlexLibrarySync(List<int> libraryIds);
|
||||||
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
|
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Plex;
|
using ErsatzTV.Core.Interfaces.Plex;
|
||||||
@@ -38,19 +39,26 @@ namespace ErsatzTV.Core.Plex
|
|||||||
await entries.Match(
|
await entries.Match(
|
||||||
async movieEntries =>
|
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: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
|
||||||
// TODO: figure out how to rebuild playlists
|
// TODO: figure out how to rebuild playlists
|
||||||
Either<BaseError, PlexMovie> maybeMovie = await _movieRepository
|
Either<BaseError, PlexMovie> maybeMovie = await _movieRepository
|
||||||
.GetOrAdd(plexMediaSourceLibrary, entry)
|
.GetOrAdd(plexMediaSourceLibrary, incoming)
|
||||||
.BindT(UpdateIfNeeded);
|
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
|
||||||
|
.BindT(existing => UpdateMetadata(existing, incoming))
|
||||||
|
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||||
|
|
||||||
maybeMovie.IfLeft(
|
await maybeMovie.Match(
|
||||||
error => _logger.LogWarning(
|
async movie => await _movieRepository.Update(movie),
|
||||||
"Error processing plex movie at {Key}: {Error}",
|
error =>
|
||||||
entry.Key,
|
{
|
||||||
error.Value));
|
_logger.LogWarning(
|
||||||
|
"Error processing plex movie at {Key}: {Error}",
|
||||||
|
incoming.Key,
|
||||||
|
error.Value);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error =>
|
error =>
|
||||||
@@ -68,10 +76,103 @@ namespace ErsatzTV.Core.Plex
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<Either<BaseError, PlexMovie>> UpdateIfNeeded(PlexMovie plexMovie) =>
|
private async Task<Either<BaseError, PlexMovie>> UpdateStatistics(
|
||||||
// .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
PlexMovie existing,
|
||||||
// .BindT(UpdateMetadata)
|
PlexMovie incoming,
|
||||||
// .BindT(UpdatePoster);
|
PlexConnection connection,
|
||||||
Right<BaseError, PlexMovie>(plexMovie).AsTask();
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,14 +160,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Delete(int id)
|
public async Task Delete(int mediaSourceId)
|
||||||
{
|
{
|
||||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||||
MediaSource mediaSource = await context.MediaSources.FindAsync(id);
|
MediaSource mediaSource = await context.MediaSources.FindAsync(mediaSourceId);
|
||||||
context.MediaSources.Remove(mediaSource);
|
context.MediaSources.Remove(mediaSource);
|
||||||
await context.SaveChangesAsync();
|
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)
|
public async Task DisablePlexLibrarySync(List<int> libraryIds)
|
||||||
{
|
{
|
||||||
await _dbConnection.ExecuteAsync(
|
await _dbConnection.ExecuteAsync(
|
||||||
|
|||||||
@@ -72,10 +72,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
|
|
||||||
public async Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item)
|
public async Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item)
|
||||||
{
|
{
|
||||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
Option<PlexMovie> maybeExisting = await _dbContext.PlexMovies
|
||||||
Option<PlexMovie> maybeExisting = await context.PlexMovies
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(i => i.MovieMetadata)
|
.Include(i => i.MovieMetadata)
|
||||||
|
.ThenInclude(mm => mm.Genres)
|
||||||
|
.Include(i => i.MovieMetadata)
|
||||||
|
.ThenInclude(mm => mm.Artwork)
|
||||||
.Include(i => i.MediaVersions)
|
.Include(i => i.MediaVersions)
|
||||||
.ThenInclude(mv => mv.MediaFiles)
|
.ThenInclude(mv => mv.MediaFiles)
|
||||||
.OrderBy(i => i.Key)
|
.OrderBy(i => i.Key)
|
||||||
@@ -83,7 +84,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
|
|
||||||
return await maybeExisting.Match(
|
return await maybeExisting.Match(
|
||||||
plexMovie => Right<BaseError, PlexMovie>(plexMovie).AsTask(),
|
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)
|
public async Task<bool> Update(Movie movie)
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
public Task<Option<DateTimeOffset>> GetNextItemStart(int channelId, DateTimeOffset now) =>
|
public Task<Option<DateTimeOffset>> GetNextItemStart(int channelId, DateTimeOffset now) =>
|
||||||
_dbContext.PlayoutItems
|
_dbContext.PlayoutItems
|
||||||
.Where(pi => pi.Playout.ChannelId == channelId)
|
.Where(pi => pi.Playout.ChannelId == channelId)
|
||||||
.Where(pi => pi.Finish > now.UtcDateTime)
|
.Where(pi => pi.Start > now.UtcDateTime)
|
||||||
.OrderBy(pi => pi.Finish)
|
.OrderBy(pi => pi.Start)
|
||||||
.FirstOrDefaultAsync()
|
.FirstOrDefaultAsync()
|
||||||
.Map(Optional)
|
.Map(Optional)
|
||||||
.MapT(pi => pi.StartOffset);
|
.MapT(pi => pi.StartOffset);
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ namespace ErsatzTV.Infrastructure.Locking
|
|||||||
public class EntityLocker : IEntityLocker
|
public class EntityLocker : IEntityLocker
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<int, byte> _lockedMediaSources;
|
private readonly ConcurrentDictionary<int, byte> _lockedMediaSources;
|
||||||
|
private bool _plex;
|
||||||
|
|
||||||
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
|
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
|
||||||
|
|
||||||
public event EventHandler OnLibraryChanged;
|
public event EventHandler OnLibraryChanged;
|
||||||
|
|
||||||
|
public event EventHandler OnPlexChanged;
|
||||||
|
|
||||||
public bool LockLibrary(int mediaSourceId)
|
public bool LockLibrary(int mediaSourceId)
|
||||||
{
|
{
|
||||||
if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0))
|
if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0))
|
||||||
@@ -36,5 +39,31 @@ namespace ErsatzTV.Infrastructure.Locking
|
|||||||
|
|
||||||
public bool IsLibraryLocked(int mediaSourceId) =>
|
public bool IsLibraryLocked(int mediaSourceId) =>
|
||||||
_lockedMediaSources.ContainsKey(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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,12 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
string key,
|
string key,
|
||||||
[Query] [AliasAs("X-Plex-Token")]
|
[Query] [AliasAs("X-Plex-Token")]
|
||||||
string token);
|
string token);
|
||||||
|
|
||||||
|
[Get("/library/metadata/{key}")]
|
||||||
|
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
|
||||||
|
GetMetadata(
|
||||||
|
string key,
|
||||||
|
[Query] [AliasAs("X-Plex-Token")]
|
||||||
|
string token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
|
|
||||||
[Get("/resources")]
|
[Get("/resources")]
|
||||||
Task<List<PlexResource>> GetResources(
|
Task<List<PlexResource>> GetResources(
|
||||||
|
[Query] [AliasAs("includeHttps")]
|
||||||
|
int includeHttps,
|
||||||
[Header("X-Plex-Client-Identifier")]
|
[Header("X-Plex-Client-Identifier")]
|
||||||
string clientIdentifier,
|
string clientIdentifier,
|
||||||
[Header("X-Plex-Token")]
|
[Header("X-Plex-Token")]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,6 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
|||||||
public int AddedAt { get; set; }
|
public int AddedAt { get; set; }
|
||||||
public int UpdatedAt { get; set; }
|
public int UpdatedAt { get; set; }
|
||||||
public List<PlexMediaResponse> Media { get; set; }
|
public List<PlexMediaResponse> Media { get; set; }
|
||||||
|
public List<PlexGenreResponse> Genre { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||||
{
|
{
|
||||||
public class PlexPartResponse
|
public class PlexPartResponse
|
||||||
{
|
{
|
||||||
@@ -6,5 +8,6 @@
|
|||||||
public string Key { get; set; }
|
public string Key { get; set; }
|
||||||
public int Duration { get; set; }
|
public int Duration { get; set; }
|
||||||
public string File { get; set; }
|
public string File { get; set; }
|
||||||
|
public List<PlexStreamResponse> Stream { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
|||||||
|
|
||||||
public bool Owned { get; set; }
|
public bool Owned { get; set; }
|
||||||
public string Provides { get; set; }
|
public string Provides { get; set; }
|
||||||
|
public bool HttpsRequired { get; set; }
|
||||||
public List<PlexResourceConnection> Connections { get; set; }
|
public List<PlexResourceConnection> Connections { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,6 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
tokens => tokens.Map(kvp => new PlexUserAuthToken(kvp.Key, kvp.Value)).ToList(),
|
tokens => tokens.Map(kvp => new PlexUserAuthToken(kvp.Key, kvp.Value)).ToList(),
|
||||||
() => new List<PlexUserAuthToken>()));
|
() => 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) =>
|
public Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier) =>
|
||||||
ReadSecrets().Map(
|
ReadSecrets().Map(
|
||||||
s => Optional(s.ServerAuthTokens.SingleOrDefault(kvp => kvp.Key == clientIdentifier))
|
s => Optional(s.ServerAuthTokens.SingleOrDefault(kvp => kvp.Key == clientIdentifier))
|
||||||
@@ -61,6 +55,9 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
return SaveSecrets(secrets);
|
return SaveSecrets(secrets);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public Task<Unit> DeleteAll() =>
|
||||||
|
ReadSecrets().Bind(secrets => SaveSecrets(new PlexSecrets { ClientIdentifier = secrets.ClientIdentifier }));
|
||||||
|
|
||||||
private static Task<PlexSecrets> ReadSecrets() =>
|
private static Task<PlexSecrets> ReadSecrets() =>
|
||||||
File.ReadAllTextAsync(FileSystemLayout.PlexSecretsPath)
|
File.ReadAllTextAsync(FileSystemLayout.PlexSecretsPath)
|
||||||
.Map(JsonConvert.DeserializeObject<PlexSecrets>)
|
.Map(JsonConvert.DeserializeObject<PlexSecrets>)
|
||||||
|
|||||||
@@ -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) =>
|
private static Option<PlexLibrary> Project(PlexLibraryResponse response) =>
|
||||||
response.Type switch
|
response.Type switch
|
||||||
{
|
{
|
||||||
@@ -99,7 +120,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
Year = response.Year,
|
Year = response.Year,
|
||||||
Tagline = response.Tagline,
|
Tagline = response.Tagline,
|
||||||
DateAdded = dateAdded,
|
DateAdded = dateAdded,
|
||||||
DateUpdated = lastWriteTime
|
DateUpdated = lastWriteTime,
|
||||||
|
Genres = response.Genre.Map(g => new Genre { Name = g.Tag }).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(response.Thumb))
|
if (!string.IsNullOrWhiteSpace(response.Thumb))
|
||||||
@@ -117,6 +139,21 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
metadata.Artwork.Add(artwork);
|
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
|
var version = new MediaVersion
|
||||||
{
|
{
|
||||||
Name = "Main",
|
Name = "Main",
|
||||||
@@ -126,7 +163,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
AudioCodec = media.AudioCodec,
|
AudioCodec = media.AudioCodec,
|
||||||
VideoCodec = media.VideoCodec,
|
VideoCodec = media.VideoCodec,
|
||||||
VideoProfile = media.VideoProfile,
|
VideoProfile = media.VideoProfile,
|
||||||
SampleAspectRatio = ConvertToSAR(media.AspectRatio),
|
// specifically omit sample aspect ratio
|
||||||
|
DateUpdated = lastWriteTime,
|
||||||
MediaFiles = new List<MediaFile>
|
MediaFiles = new List<MediaFile>
|
||||||
{
|
{
|
||||||
new PlexMediaFile
|
new PlexMediaFile
|
||||||
@@ -148,8 +186,21 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
return movie;
|
return movie;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ConvertToSAR(double aspectRatio) => "1:1";
|
private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
|
||||||
// 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";
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,22 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
string clientIdentifier = await _plexSecretStore.GetClientIdentifier();
|
string clientIdentifier = await _plexSecretStore.GetClientIdentifier();
|
||||||
foreach (PlexUserAuthToken token in await _plexSecretStore.GetUserAuthTokens())
|
foreach (PlexUserAuthToken token in await _plexSecretStore.GetUserAuthTokens())
|
||||||
{
|
{
|
||||||
List<PlexResource> resources = await _plexTvApi.GetResources(clientIdentifier, token.AuthToken);
|
List<PlexResource> httpResources = await _plexTvApi.GetResources(
|
||||||
IEnumerable<PlexMediaSource> sources = resources
|
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.Provides.Split(",").Any(p => p == "server"))
|
||||||
.Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future
|
.Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future
|
||||||
.Map(
|
.Map(
|
||||||
@@ -46,13 +60,16 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
resource.AccessToken);
|
resource.AccessToken);
|
||||||
|
|
||||||
_plexSecretStore.UpsertServerAuthToken(serverAuthToken);
|
_plexSecretStore.UpsertServerAuthToken(serverAuthToken);
|
||||||
|
List<PlexResourceConnection> sortedConnections = resource.HttpsRequired
|
||||||
|
? resource.Connections
|
||||||
|
: resource.Connections.OrderBy(c => c.Local ? 0 : 1).ToList();
|
||||||
|
|
||||||
var source = new PlexMediaSource
|
var source = new PlexMediaSource
|
||||||
{
|
{
|
||||||
ServerName = resource.Name,
|
ServerName = resource.Name,
|
||||||
ProductVersion = resource.ProductVersion,
|
ProductVersion = resource.ProductVersion,
|
||||||
ClientIdentifier = resource.ClientIdentifier,
|
ClientIdentifier = resource.ClientIdentifier,
|
||||||
Connections = resource.Connections
|
Connections = sortedConnections
|
||||||
.Map(c => new PlexConnection { Uri = c.Uri }).ToList()
|
.Map(c => new PlexConnection { Uri = c.Uri }).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}")]
|
[HttpGet("/artwork/thumbnails/{fileName}")]
|
||||||
public async Task<IActionResult> GetThumbnail(string fileName)
|
public async Task<IActionResult> GetThumbnail(string fileName)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||||
@inject IEntityLocker Locker
|
@inject IEntityLocker Locker
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||||
<MudTable Hover="true" Items="_libraries" Dense="true">
|
<MudTable Hover="true" Items="_libraries" Dense="true">
|
||||||
<ToolBarContent>
|
<ToolBarContent>
|
||||||
<MudText Typo="Typo.h6"><b>@_source.Name</b> Libraries</MudText>
|
<MudText Typo="Typo.h6"><b>@_source.Name</b> Libraries</MudText>
|
||||||
@@ -28,16 +28,7 @@
|
|||||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||||
<MudTd DataLabel="MediaType">@context.MediaKind</MudTd>
|
<MudTd DataLabel="MediaType">@context.MediaKind</MudTd>
|
||||||
<MudTd DataLabel="Synchronize">
|
<MudTd DataLabel="Synchronize">
|
||||||
<div style="display: flex; justify-content: center">
|
<MudSwitch T="bool" @bind-Checked="@context.ShouldSyncItems" Color="Color.Primary"/>
|
||||||
@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>
|
|
||||||
</MudTd>
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
@using ErsatzTV.Application.Plex
|
@using ErsatzTV.Application.Plex
|
||||||
@using ErsatzTV.Application.Plex.Commands
|
@using ErsatzTV.Application.Plex.Commands
|
||||||
@using ErsatzTV.Application.Plex.Queries
|
@using ErsatzTV.Application.Plex.Queries
|
||||||
|
@implements IDisposable
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
@inject IMediator Mediator
|
@inject IMediator Mediator
|
||||||
|
@inject IEntityLocker Locker
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject ILogger<PlexMediaSources> Logger
|
@inject ILogger<PlexMediaSources> Logger
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||||
<MudTable Hover="true" Dense="true" Items="_mediaSources">
|
<MudTable Hover="true" Dense="true" Items="_mediaSources">
|
||||||
<ToolBarContent>
|
<ToolBarContent>
|
||||||
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
|
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
|
||||||
@@ -42,9 +44,26 @@
|
|||||||
</MudTd>
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
@* <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlexMediaSource())" Class="mt-4"> *@
|
@if (_mediaSources.Any())
|
||||||
@* Add Plex Media Source *@
|
{
|
||||||
@* </MudButton> *@
|
<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>
|
</MudContainer>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -52,46 +71,50 @@
|
|||||||
|
|
||||||
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
|
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
|
||||||
|
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
Locker.OnPlexChanged += PlexChanged;
|
||||||
|
|
||||||
private async Task LoadMediaSources() =>
|
private async Task LoadMediaSources() =>
|
||||||
_mediaSources = await Mediator.Send(new GetAllPlexMediaSources());
|
_mediaSources = await Mediator.Send(new GetAllPlexMediaSources());
|
||||||
|
|
||||||
// private async Task DeleteMediaSource(PlexMediaSourceViewModel mediaSource)
|
private async Task SignOutOfPlex()
|
||||||
// {
|
{
|
||||||
// int count = await Mediator.Send(new CountMediaItemsById(mediaSource.Id));
|
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
|
||||||
//
|
IDialogReference dialog = Dialog.Show<SignOutOfPlexDialog>("Sign out of Plex", options);
|
||||||
// var parameters = new DialogParameters
|
DialogResult result = await dialog.Result;
|
||||||
// {
|
if (!result.Cancelled)
|
||||||
// { "EntityType", "media source" },
|
{
|
||||||
// { "EntityName", mediaSource.Name },
|
if (Locker.LockPlex())
|
||||||
// { "DetailText", $"This media source contains {count} media items." },
|
{
|
||||||
// { "DetailHighlight", count.ToString() }
|
await Mediator.Send(new SignOutOfPlex());
|
||||||
// };
|
await LoadMediaSources();
|
||||||
// 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 AddPlexMediaSource()
|
private async Task AddPlexMediaSource()
|
||||||
{
|
{
|
||||||
Either<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow());
|
if (Locker.LockPlex())
|
||||||
await maybeUrl.Match(
|
{
|
||||||
async url => await JsRuntime.InvokeAsync<object>("open", new object[] { url, "_blank" }),
|
Either<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow());
|
||||||
error =>
|
await maybeUrl.Match(
|
||||||
{
|
async url => await JsRuntime.InvokeAsync<object>("open", new object[] { url, "_blank" }),
|
||||||
Snackbar.Add(error.Value, Severity.Error);
|
error =>
|
||||||
Logger.LogError("Unexpected error generating plex auth app url: {Error}", error.Value);
|
{
|
||||||
return Task.CompletedTask;
|
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;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IMediator Mediator
|
@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">
|
<MudTable Hover="true" Items="_pathReplacements.OrderBy(r => r.Id)" Dense="true" @bind-SelectedItem="_selectedItem">
|
||||||
<ToolBarContent>
|
<ToolBarContent>
|
||||||
<MudText Typo="Typo.h6"><b>@_source.Name</b> Path Replacements</MudText>
|
<MudText Typo="Typo.h6"><b>@_source.Name</b> Path Replacements</MudText>
|
||||||
|
|||||||
@@ -46,13 +46,7 @@ namespace ErsatzTV.Services
|
|||||||
FileSystemLayout.PlexSecretsPath);
|
FileSystemLayout.PlexSecretsPath);
|
||||||
|
|
||||||
// synchronize sources on startup
|
// synchronize sources on startup
|
||||||
List<PlexMediaSource> sources = await SynchronizeSources(
|
await SynchronizeSources(new SynchronizePlexMediaSources(), cancellationToken);
|
||||||
new SynchronizePlexMediaSources(),
|
|
||||||
cancellationToken);
|
|
||||||
foreach (PlexMediaSource source in sources)
|
|
||||||
{
|
|
||||||
await SynchronizeLibraries(new SynchronizePlexLibraries(source.Id), cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await foreach (IPlexBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
await foreach (IPlexBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
@@ -64,6 +58,9 @@ namespace ErsatzTV.Services
|
|||||||
SynchronizePlexMediaSources sourcesRequest => SynchronizeSources(
|
SynchronizePlexMediaSources sourcesRequest => SynchronizeSources(
|
||||||
sourcesRequest,
|
sourcesRequest,
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
|
SynchronizePlexLibraries synchronizePlexLibrariesRequest => SynchronizeLibraries(
|
||||||
|
synchronizePlexLibrariesRequest,
|
||||||
|
cancellationToken),
|
||||||
_ => throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}")
|
_ => throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,9 @@
|
|||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavLink Href="/channels">Channels</MudNavLink>
|
<MudNavLink Href="/channels">Channels</MudNavLink>
|
||||||
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
|
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
|
||||||
@* <MudNavGroup Title="Media Sources" Expanded="true"> *@
|
<MudNavGroup Title="Media Sources" Expanded="true">
|
||||||
@* <MudNavLink Href="/media/plex">Plex</MudNavLink> *@
|
<MudNavLink Href="/media/plex">Plex</MudNavLink>
|
||||||
@* </MudNavGroup> *@
|
</MudNavGroup>
|
||||||
<MudNavGroup Title="Media" Expanded="true">
|
<MudNavGroup Title="Media" Expanded="true">
|
||||||
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
|
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
|
||||||
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
|
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
}
|
||||||
+1
-1
@@ -208,7 +208,7 @@ namespace ErsatzTV
|
|||||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||||
|
|
||||||
// services.AddHostedService<PlexService>();
|
services.AddHostedService<PlexService>();
|
||||||
services.AddHostedService<FFmpegLocatorService>();
|
services.AddHostedService<FFmpegLocatorService>();
|
||||||
services.AddHostedService<WorkerService>();
|
services.AddHostedService<WorkerService>();
|
||||||
services.AddHostedService<SchedulerService>();
|
services.AddHostedService<SchedulerService>();
|
||||||
|
|||||||
Reference in New Issue
Block a user