diff --git a/ErsatzTV.Application/Emby/Commands/DisconnectEmby.cs b/ErsatzTV.Application/Emby/Commands/DisconnectEmby.cs new file mode 100644 index 000000000..29f6474fc --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/DisconnectEmby.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public record DisconnectEmby : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs b/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs new file mode 100644 index 000000000..1d3ed99a8 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public class DisconnectEmbyHandler : MediatR.IRequestHandler> + { + private readonly IEmbySecretStore _embySecretStore; + private readonly IEntityLocker _entityLocker; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly ISearchIndex _searchIndex; + + public DisconnectEmbyHandler( + IMediaSourceRepository mediaSourceRepository, + IEmbySecretStore embySecretStore, + IEntityLocker entityLocker, + ISearchIndex searchIndex) + { + _mediaSourceRepository = mediaSourceRepository; + _embySecretStore = embySecretStore; + _entityLocker = entityLocker; + _searchIndex = searchIndex; + } + + public async Task> Handle( + DisconnectEmby request, + CancellationToken cancellationToken) + { + List ids = await _mediaSourceRepository.DeleteAllEmby(); + await _searchIndex.RemoveItems(ids); + await _embySecretStore.DeleteAll(); + _entityLocker.UnlockRemoteMediaSource(); + + return Unit.Default; + } + } +} diff --git a/ErsatzTV.Application/Emby/Commands/SaveEmbySecrets.cs b/ErsatzTV.Application/Emby/Commands/SaveEmbySecrets.cs new file mode 100644 index 000000000..5657d165f --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SaveEmbySecrets.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Emby; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public record SaveEmbySecrets(EmbySecrets Secrets) : MediatR.IRequest>; +} diff --git a/ErsatzTV.Application/Emby/Commands/SaveEmbySecretsHandler.cs b/ErsatzTV.Application/Emby/Commands/SaveEmbySecretsHandler.cs new file mode 100644 index 000000000..7fd4ecb06 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SaveEmbySecretsHandler.cs @@ -0,0 +1,60 @@ +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public class SaveEmbySecretsHandler : MediatR.IRequestHandler> + { + private readonly ChannelWriter _channel; + private readonly IEmbyApiClient _embyApiClient; + private readonly IEmbySecretStore _embySecretStore; + private readonly IMediaSourceRepository _mediaSourceRepository; + + public SaveEmbySecretsHandler( + IEmbySecretStore embySecretStore, + IEmbyApiClient embyApiClient, + IMediaSourceRepository mediaSourceRepository, + ChannelWriter channel) + { + _embySecretStore = embySecretStore; + _embyApiClient = embyApiClient; + _mediaSourceRepository = mediaSourceRepository; + _channel = channel; + } + + public Task> Handle(SaveEmbySecrets request, CancellationToken cancellationToken) => + Validate(request) + .MapT(PerformSave) + .Bind(v => v.ToEitherAsync()); + + private async Task> Validate(SaveEmbySecrets request) + { + Either maybeServerInformation = await _embyApiClient + .GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey); + + return maybeServerInformation.Match( + info => Validation.Success(new Parameters(request.Secrets, info)), + error => error); + } + + private async Task PerformSave(Parameters parameters) + { + await _embySecretStore.SaveSecrets(parameters.Secrets); + await _mediaSourceRepository.UpsertEmby( + parameters.Secrets.Address, + parameters.ServerInformation.ServerName, + parameters.ServerInformation.OperatingSystem); + await _channel.WriteAsync(new SynchronizeEmbyMediaSources()); + + return Unit.Default; + } + + private record Parameters(EmbySecrets Secrets, EmbyServerInformation ServerInformation); + } +} diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraries.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraries.cs new file mode 100644 index 000000000..1430d2420 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraries.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : MediatR.IRequest>, + IEmbyBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibrariesHandler.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibrariesHandler.cs new file mode 100644 index 000000000..368fdb257 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibrariesHandler.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Emby.Commands +{ + public class + SynchronizeEmbyLibrariesHandler : MediatR.IRequestHandler> + { + private readonly IEmbyApiClient _embyApiClient; + private readonly IEmbySecretStore _embySecretStore; + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + + public SynchronizeEmbyLibrariesHandler( + IMediaSourceRepository mediaSourceRepository, + IEmbySecretStore embySecretStore, + IEmbyApiClient embyApiClient, + ILogger logger) + { + _mediaSourceRepository = mediaSourceRepository; + _embySecretStore = embySecretStore; + _embyApiClient = embyApiClient; + _logger = logger; + } + + public Task> Handle( + SynchronizeEmbyLibraries request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(SynchronizeLibraries) + .Bind(v => v.ToEitherAsync()); + + private Task> Validate(SynchronizeEmbyLibraries request) => + MediaSourceMustExist(request) + .BindT(MediaSourceMustHaveActiveConnection) + .BindT(MediaSourceMustHaveApiKey); + + private Task> MediaSourceMustExist( + SynchronizeEmbyLibraries request) => + _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId) + .Map(o => o.ToValidation("Emby media source does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + EmbyMediaSource embyMediaSource) + { + Option maybeConnection = embyMediaSource.Connections.HeadOrNone(); + return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection)) + .ToValidation("Emby media source requires an active connection"); + } + + private async Task> MediaSourceMustHaveApiKey( + ConnectionParameters connectionParameters) + { + EmbySecrets secrets = await _embySecretStore.ReadSecrets(); + return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) + .Filter(match => match) + .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) + .ToValidation("Emby media source requires an api key"); + } + + private async Task SynchronizeLibraries(ConnectionParameters connectionParameters) + { + Either> maybeLibraries = await _embyApiClient.GetLibraries( + connectionParameters.ActiveConnection.Address, + connectionParameters.ApiKey); + + await maybeLibraries.Match( + libraries => + { + var existing = connectionParameters.EmbyMediaSource.Libraries.OfType() + .ToList(); + var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList(); + var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList(); + return _mediaSourceRepository.UpdateLibraries( + connectionParameters.EmbyMediaSource.Id, + toAdd, + toRemove); + }, + error => + { + _logger.LogWarning( + "Unable to synchronize libraries from emby server {EmbyServer}: {Error}", + connectionParameters.EmbyMediaSource.ServerName, + error.Value); + + return Task.CompletedTask; + }); + + return Unit.Default; + } + + private record ConnectionParameters( + EmbyMediaSource EmbyMediaSource, + EmbyConnection ActiveConnection) + { + public string ApiKey { get; set; } + } + } +} diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryById.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryById.cs new file mode 100644 index 000000000..7cadcc141 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryById.cs @@ -0,0 +1,23 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Emby.Commands +{ + public interface ISynchronizeEmbyLibraryById : IRequest>, + IEmbyBackgroundServiceRequest + { + int EmbyLibraryId { get; } + bool ForceScan { get; } + } + + public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById + { + public bool ForceScan => false; + } + + public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId) : ISynchronizeEmbyLibraryById + { + public bool ForceScan => true; + } +} diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs new file mode 100644 index 000000000..8abcff966 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs @@ -0,0 +1,172 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Application.Emby.Commands +{ + public class SynchronizeEmbyLibraryByIdHandler : + IRequestHandler>, + IRequestHandler> + { + private readonly IConfigElementRepository _configElementRepository; + private readonly IEmbyMovieLibraryScanner _embyMovieLibraryScanner; + + private readonly IEmbySecretStore _embySecretStore; + private readonly IEmbyTelevisionLibraryScanner _embyTelevisionLibraryScanner; + private readonly IEntityLocker _entityLocker; + private readonly ILibraryRepository _libraryRepository; + private readonly ILogger _logger; + + private readonly IMediaSourceRepository _mediaSourceRepository; + + public SynchronizeEmbyLibraryByIdHandler( + IMediaSourceRepository mediaSourceRepository, + IEmbySecretStore embySecretStore, + IEmbyMovieLibraryScanner embyMovieLibraryScanner, + IEmbyTelevisionLibraryScanner embyTelevisionLibraryScanner, + ILibraryRepository libraryRepository, + IEntityLocker entityLocker, + IConfigElementRepository configElementRepository, + ILogger logger) + { + _mediaSourceRepository = mediaSourceRepository; + _embySecretStore = embySecretStore; + _embyMovieLibraryScanner = embyMovieLibraryScanner; + _embyTelevisionLibraryScanner = embyTelevisionLibraryScanner; + _libraryRepository = libraryRepository; + _entityLocker = entityLocker; + _configElementRepository = configElementRepository; + _logger = logger; + } + + public Task> Handle( + ForceSynchronizeEmbyLibraryById request, + CancellationToken cancellationToken) => Handle(request); + + public Task> Handle( + SynchronizeEmbyLibraryByIdIfNeeded request, + CancellationToken cancellationToken) => Handle(request); + + private Task> + Handle(ISynchronizeEmbyLibraryById request) => + Validate(request) + .MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name)) + .Bind(v => v.ToEitherAsync()); + + private async Task Synchronize(RequestParameters parameters) + { + var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero); + if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6)) + { + switch (parameters.Library.MediaKind) + { + case LibraryMediaKind.Movies: + await _embyMovieLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.FFprobePath); + break; + case LibraryMediaKind.Shows: + await _embyTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.FFprobePath); + break; + } + + parameters.Library.LastScan = DateTime.UtcNow; + await _libraryRepository.UpdateLastScan(parameters.Library); + } + else + { + _logger.LogDebug( + "Skipping unforced scan of emby media library {Name}", + parameters.Library.Name); + } + + _entityLocker.UnlockLibrary(parameters.Library.Id); + return Unit.Default; + } + + private async Task> Validate( + ISynchronizeEmbyLibraryById request) => + (await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath()) + .Apply( + (connectionParameters, embyLibrary, ffprobePath) => new RequestParameters( + connectionParameters, + embyLibrary, + request.ForceScan, + ffprobePath + )); + + private Task> ValidateConnection( + ISynchronizeEmbyLibraryById request) => + EmbyMediaSourceMustExist(request) + .BindT(MediaSourceMustHaveActiveConnection) + .BindT(MediaSourceMustHaveApiKey); + + private Task> EmbyMediaSourceMustExist( + ISynchronizeEmbyLibraryById request) => + _mediaSourceRepository.GetEmbyByLibraryId(request.EmbyLibraryId) + .Map( + v => v.ToValidation( + $"Emby media source for library {request.EmbyLibraryId} does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + EmbyMediaSource embyMediaSource) + { + Option maybeConnection = embyMediaSource.Connections.HeadOrNone(); + return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection)) + .ToValidation("Emby media source requires an active connection"); + } + + private async Task> MediaSourceMustHaveApiKey( + ConnectionParameters connectionParameters) + { + EmbySecrets secrets = await _embySecretStore.ReadSecrets(); + return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) + .Filter(match => match) + .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) + .ToValidation("Emby media source requires an api key"); + } + + private Task> EmbyLibraryMustExist( + ISynchronizeEmbyLibraryById request) => + _mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId) + .Map(v => v.ToValidation($"Emby library {request.EmbyLibraryId} does not exist.")); + + private Task> ValidateFFprobePath() => + _configElementRepository.GetValue(ConfigElementKey.FFprobePath) + .FilterT(File.Exists) + .Map( + ffprobePath => + ffprobePath.ToValidation("FFprobe path does not exist on the file system")); + + private record RequestParameters( + ConnectionParameters ConnectionParameters, + EmbyLibrary Library, + bool ForceScan, + string FFprobePath); + + private record ConnectionParameters( + EmbyMediaSource EmbyMediaSource, + EmbyConnection ActiveConnection) + { + public string ApiKey { get; set; } + } + } +} diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyMediaSources.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyMediaSources.cs new file mode 100644 index 000000000..2ab43890b --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyMediaSources.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Emby.Commands +{ + public record SynchronizeEmbyMediaSources : IRequest>>, + IEmbyBackgroundServiceRequest; +} diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyMediaSourcesHandler.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyMediaSourcesHandler.cs new file mode 100644 index 000000000..8a0fc9cb9 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyMediaSourcesHandler.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Emby.Commands +{ + public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler>> + { + private readonly ChannelWriter _channel; + private readonly IMediaSourceRepository _mediaSourceRepository; + + public SynchronizeEmbyMediaSourcesHandler( + IMediaSourceRepository mediaSourceRepository, + ChannelWriter channel) + { + _mediaSourceRepository = mediaSourceRepository; + _channel = channel; + } + + public async Task>> Handle( + SynchronizeEmbyMediaSources request, + CancellationToken cancellationToken) + { + List mediaSources = await _mediaSourceRepository.GetAllEmby(); + foreach (EmbyMediaSource mediaSource in mediaSources) + { + // await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken); + await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken); + } + + return mediaSources; + } + } +} diff --git a/ErsatzTV.Application/Emby/Commands/UpdateEmbyLibraryPreferences.cs b/ErsatzTV.Application/Emby/Commands/UpdateEmbyLibraryPreferences.cs new file mode 100644 index 000000000..0ce00cad2 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/UpdateEmbyLibraryPreferences.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public record UpdateEmbyLibraryPreferences + (List Preferences) : MediatR.IRequest>; + + public record EmbyLibraryPreference(int Id, bool ShouldSyncItems); +} diff --git a/ErsatzTV.Application/Emby/Commands/UpdateEmbyLibraryPreferencesHandler.cs b/ErsatzTV.Application/Emby/Commands/UpdateEmbyLibraryPreferencesHandler.cs new file mode 100644 index 000000000..09bc158b3 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/UpdateEmbyLibraryPreferencesHandler.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public class + UpdateEmbyLibraryPreferencesHandler : MediatR.IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly ISearchIndex _searchIndex; + + public UpdateEmbyLibraryPreferencesHandler( + IMediaSourceRepository mediaSourceRepository, + ISearchIndex searchIndex) + { + _mediaSourceRepository = mediaSourceRepository; + _searchIndex = searchIndex; + } + + public async Task> Handle( + UpdateEmbyLibraryPreferences request, + CancellationToken cancellationToken) + { + var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList(); + List ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable); + await _searchIndex.RemoveItems(ids); + + IEnumerable toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id); + await _mediaSourceRepository.EnableEmbyLibrarySync(toEnable); + + return Unit.Default; + } + } +} diff --git a/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacements.cs b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacements.cs new file mode 100644 index 000000000..6626cc5e4 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacements.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public record UpdateEmbyPathReplacements( + int EmbyMediaSourceId, + List PathReplacements) : MediatR.IRequest>; + + public record EmbyPathReplacementItem(int Id, string EmbyPath, string LocalPath); +} diff --git a/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs new file mode 100644 index 000000000..fe0af60d4 --- /dev/null +++ b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Application.Emby.Commands +{ + public class UpdateEmbyPathReplacementsHandler : MediatR.IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + UpdateEmbyPathReplacements request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(pms => MergePathReplacements(request, pms)) + .Bind(v => v.ToEitherAsync()); + + private Task MergePathReplacements( + UpdateEmbyPathReplacements request, + EmbyMediaSource embyMediaSource) + { + embyMediaSource.PathReplacements ??= new List(); + + var incoming = request.PathReplacements.Map(Project).ToList(); + + var toAdd = incoming.Filter(r => r.Id < 1).ToList(); + var toRemove = embyMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList(); + var toUpdate = incoming.Except(toAdd).ToList(); + + return _mediaSourceRepository.UpdatePathReplacements(embyMediaSource.Id, toAdd, toUpdate, toRemove); + } + + private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) => + new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath }; + + private Task> Validate(UpdateEmbyPathReplacements request) => + EmbyMediaSourceMustExist(request); + + private Task> EmbyMediaSourceMustExist( + UpdateEmbyPathReplacements request) => + _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId) + .Map( + v => v.ToValidation( + $"Emby media source {request.EmbyMediaSourceId} does not exist.")); + } +} diff --git a/ErsatzTV.Application/Emby/EmbyLibraryViewModel.cs b/ErsatzTV.Application/Emby/EmbyLibraryViewModel.cs new file mode 100644 index 000000000..afde64b28 --- /dev/null +++ b/ErsatzTV.Application/Emby/EmbyLibraryViewModel.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Emby +{ + public record EmbyLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems) + : LibraryViewModel("Emby", Id, Name, MediaKind); +} diff --git a/ErsatzTV.Application/Emby/EmbyMediaSourceViewModel.cs b/ErsatzTV.Application/Emby/EmbyMediaSourceViewModel.cs new file mode 100644 index 000000000..b78a10c42 --- /dev/null +++ b/ErsatzTV.Application/Emby/EmbyMediaSourceViewModel.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Application.MediaSources; + +namespace ErsatzTV.Application.Emby +{ + public record EmbyMediaSourceViewModel(int Id, string Name, string Address) : RemoteMediaSourceViewModel( + Id, + Name, + Address); +} diff --git a/ErsatzTV.Application/Emby/EmbyPathReplacementViewModel.cs b/ErsatzTV.Application/Emby/EmbyPathReplacementViewModel.cs new file mode 100644 index 000000000..e6da43b0c --- /dev/null +++ b/ErsatzTV.Application/Emby/EmbyPathReplacementViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Emby +{ + public record EmbyPathReplacementViewModel(int Id, string EmbyPath, string LocalPath); +} diff --git a/ErsatzTV.Application/Emby/Mapper.cs b/ErsatzTV.Application/Emby/Mapper.cs new file mode 100644 index 000000000..512edbab4 --- /dev/null +++ b/ErsatzTV.Application/Emby/Mapper.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Emby +{ + internal static class Mapper + { + internal static EmbyMediaSourceViewModel ProjectToViewModel(EmbyMediaSource embyMediaSource) => + new( + embyMediaSource.Id, + embyMediaSource.ServerName, + embyMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty)); + + internal static EmbyLibraryViewModel ProjectToViewModel(EmbyLibrary library) => + new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems); + + internal static EmbyPathReplacementViewModel ProjectToViewModel(EmbyPathReplacement pathReplacement) => + new(pathReplacement.Id, pathReplacement.EmbyPath, pathReplacement.LocalPath); + } +} diff --git a/ErsatzTV.Application/Emby/Queries/GetAllEmbyMediaSources.cs b/ErsatzTV.Application/Emby/Queries/GetAllEmbyMediaSources.cs new file mode 100644 index 000000000..9f0e6fc14 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetAllEmbyMediaSources.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Emby.Queries +{ + public record GetAllEmbyMediaSources : IRequest>; +} diff --git a/ErsatzTV.Application/Emby/Queries/GetAllEmbyMediaSourcesHandler.cs b/ErsatzTV.Application/Emby/Queries/GetAllEmbyMediaSourcesHandler.cs new file mode 100644 index 000000000..d7615d6e2 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetAllEmbyMediaSourcesHandler.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Emby.Mapper; + +namespace ErsatzTV.Application.Emby.Queries +{ + public class GetAllEmbyMediaSourcesHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetAllEmbyMediaSourcesHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetAllEmbyMediaSources request, + CancellationToken cancellationToken) => + _mediaSourceRepository.GetAllEmby().Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyLibrariesBySourceId.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyLibrariesBySourceId.cs new file mode 100644 index 000000000..3bfad5589 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyLibrariesBySourceId.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Emby.Queries +{ + public record GetEmbyLibrariesBySourceId(int EmbyMediaSourceId) : IRequest>; +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyLibrariesBySourceIdHandler.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyLibrariesBySourceIdHandler.cs new file mode 100644 index 000000000..758d5de64 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyLibrariesBySourceIdHandler.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Emby.Mapper; + +namespace ErsatzTV.Application.Emby.Queries +{ + public class + GetEmbyLibrariesBySourceIdHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetEmbyLibrariesBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetEmbyLibrariesBySourceId request, + CancellationToken cancellationToken) => + _mediaSourceRepository.GetEmbyLibraries(request.EmbyMediaSourceId) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyMediaSourceById.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyMediaSourceById.cs new file mode 100644 index 000000000..f79a79935 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyMediaSourceById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Emby.Queries +{ + public record GetEmbyMediaSourceById(int EmbyMediaSourceId) : IRequest>; +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyMediaSourceByIdHandler.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyMediaSourceByIdHandler.cs new file mode 100644 index 000000000..b5e759646 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyMediaSourceByIdHandler.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Emby.Mapper; + +namespace ErsatzTV.Application.Emby.Queries +{ + public class + GetEmbyMediaSourceByIdHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetEmbyMediaSourceByIdHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetEmbyMediaSourceById request, + CancellationToken cancellationToken) => + _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId).MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyPathReplacementsBySourceId.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyPathReplacementsBySourceId.cs new file mode 100644 index 000000000..c26f3d585 --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyPathReplacementsBySourceId.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Emby.Queries +{ + public record GetEmbyPathReplacementsBySourceId + (int EmbyMediaSourceId) : IRequest>; +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbyPathReplacementsBySourceIdHandler.cs b/ErsatzTV.Application/Emby/Queries/GetEmbyPathReplacementsBySourceIdHandler.cs new file mode 100644 index 000000000..e5cab338f --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbyPathReplacementsBySourceIdHandler.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Emby.Mapper; + +namespace ErsatzTV.Application.Emby.Queries +{ + public class GetEmbyPathReplacementsBySourceIdHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetEmbyPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetEmbyPathReplacementsBySourceId request, + CancellationToken cancellationToken) => + _mediaSourceRepository.GetEmbyPathReplacements(request.EmbyMediaSourceId) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbySecrets.cs b/ErsatzTV.Application/Emby/Queries/GetEmbySecrets.cs new file mode 100644 index 000000000..6123398cc --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbySecrets.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core.Emby; +using MediatR; + +namespace ErsatzTV.Application.Emby.Queries +{ + public record GetEmbySecrets : IRequest; +} diff --git a/ErsatzTV.Application/Emby/Queries/GetEmbySecretsHandler.cs b/ErsatzTV.Application/Emby/Queries/GetEmbySecretsHandler.cs new file mode 100644 index 000000000..1f075353d --- /dev/null +++ b/ErsatzTV.Application/Emby/Queries/GetEmbySecretsHandler.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Emby; +using MediatR; + +namespace ErsatzTV.Application.Emby.Queries +{ + public class GetEmbySecretsHandler : IRequestHandler + { + private readonly IEmbySecretStore _embySecretStore; + + public GetEmbySecretsHandler(IEmbySecretStore embySecretStore) => + _embySecretStore = embySecretStore; + + public Task Handle(GetEmbySecrets request, CancellationToken cancellationToken) => + _embySecretStore.ReadSecrets(); + } +} diff --git a/ErsatzTV.Application/IEmbyBackgroundServiceRequest.cs b/ErsatzTV.Application/IEmbyBackgroundServiceRequest.cs new file mode 100644 index 000000000..b35664529 --- /dev/null +++ b/ErsatzTV.Application/IEmbyBackgroundServiceRequest.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Application +{ + public interface IEmbyBackgroundServiceRequest + { + } +} diff --git a/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs index 93e33c6b6..ec5246700 100644 --- a/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs +++ b/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Repositories; @@ -36,7 +37,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands List ids = await _mediaSourceRepository.DeleteAllJellyfin(); await _searchIndex.RemoveItems(ids); await _jellyfinSecretStore.DeleteAll(); - _entityLocker.UnlockJellyfin(); + _entityLocker.UnlockRemoteMediaSource(); return Unit.Default; } diff --git a/ErsatzTV.Application/Jellyfin/JellyfinMediaSourceViewModel.cs b/ErsatzTV.Application/Jellyfin/JellyfinMediaSourceViewModel.cs index 0631b7bc5..361a6b948 100644 --- a/ErsatzTV.Application/Jellyfin/JellyfinMediaSourceViewModel.cs +++ b/ErsatzTV.Application/Jellyfin/JellyfinMediaSourceViewModel.cs @@ -2,5 +2,8 @@ namespace ErsatzTV.Application.Jellyfin { - public record JellyfinMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name); + public record JellyfinMediaSourceViewModel(int Id, string Name, string Address) : RemoteMediaSourceViewModel( + Id, + Name, + Address); } diff --git a/ErsatzTV.Application/Libraries/Mapper.cs b/ErsatzTV.Application/Libraries/Mapper.cs index 0c4fd0e7f..42ff4ddc1 100644 --- a/ErsatzTV.Application/Libraries/Mapper.cs +++ b/ErsatzTV.Application/Libraries/Mapper.cs @@ -1,4 +1,5 @@ using System; +using ErsatzTV.Application.Emby; using ErsatzTV.Application.Jellyfin; using ErsatzTV.Core.Domain; @@ -12,6 +13,7 @@ namespace ErsatzTV.Application.Libraries LocalLibrary l => ProjectToViewModel(l), PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind), JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems), + EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems), _ => throw new ArgumentOutOfRangeException(nameof(library)) }; diff --git a/ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs index 5845f26d9..eef67269f 100644 --- a/ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs +++ b/ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs @@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Libraries.Queries LocalLibrary => true, PlexLibrary plex => plex.ShouldSyncItems, JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems, + EmbyLibrary emby => emby.ShouldSyncItems, _ => false }; } diff --git a/ErsatzTV.Application/MediaCards/Mapper.cs b/ErsatzTV.Application/MediaCards/Mapper.cs index 3b52804b3..6371e2fd4 100644 --- a/ErsatzTV.Application/MediaCards/Mapper.cs +++ b/ErsatzTV.Application/MediaCards/Mapper.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; using ErsatzTV.Core.Jellyfin; using LanguageExt; using static LanguageExt.Prelude; @@ -11,17 +12,19 @@ namespace ErsatzTV.Application.MediaCards { internal static TelevisionShowCardViewModel ProjectToViewModel( ShowMetadata showMetadata, - Option maybeJellyfin) => + Option maybeJellyfin, + Option maybeEmby) => new( showMetadata.ShowId, showMetadata.Title, showMetadata.Year?.ToString(), showMetadata.SortTitle, - GetPoster(showMetadata, maybeJellyfin)); + GetPoster(showMetadata, maybeJellyfin, maybeEmby)); internal static TelevisionSeasonCardViewModel ProjectToViewModel( Season season, - Option maybeJellyfin) => + Option maybeJellyfin, + Option maybeEmby) => new( season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty), season.Id, @@ -29,12 +32,14 @@ namespace ErsatzTV.Application.MediaCards GetSeasonName(season.SeasonNumber), string.Empty, GetSeasonName(season.SeasonNumber), - season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin)).IfNone(string.Empty), + season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby)) + .IfNone(string.Empty), season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString()); internal static TelevisionEpisodeCardViewModel ProjectToViewModel( EpisodeMetadata episodeMetadata, - Option maybeJellyfin) => + Option maybeJellyfin, + Option maybeEmby) => new( episodeMetadata.EpisodeId, episodeMetadata.ReleaseDate ?? DateTime.MinValue, @@ -48,17 +53,18 @@ namespace ErsatzTV.Application.MediaCards episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match( em => em.Plot ?? string.Empty, () => string.Empty), - GetThumbnail(episodeMetadata, maybeJellyfin)); + GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby)); internal static MovieCardViewModel ProjectToViewModel( MovieMetadata movieMetadata, - Option maybeJellyfin) => + Option maybeJellyfin, + Option maybeEmby) => new( movieMetadata.MovieId, movieMetadata.Title, movieMetadata.Year?.ToString(), movieMetadata.SortTitle, - GetPoster(movieMetadata, maybeJellyfin)); + GetPoster(movieMetadata, maybeJellyfin, maybeEmby)); internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) => new( @@ -67,7 +73,7 @@ namespace ErsatzTV.Application.MediaCards musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title, musicVideoMetadata.SortTitle, musicVideoMetadata.Plot, - GetThumbnail(musicVideoMetadata, None)); + GetThumbnail(musicVideoMetadata, None, None)); internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) => new( @@ -75,28 +81,36 @@ namespace ErsatzTV.Application.MediaCards artistMetadata.Title, artistMetadata.Disambiguation, artistMetadata.SortTitle, - GetThumbnail(artistMetadata, None)); + GetThumbnail(artistMetadata, None, None)); internal static CollectionCardResultsViewModel - ProjectToViewModel(Collection collection, Option maybeJellyfin) => + ProjectToViewModel( + Collection collection, + Option maybeJellyfin, + Option maybeEmby) => new( collection.Name, collection.MediaItems.OfType().Map( - m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin) with + m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with { CustomIndex = GetCustomIndex(collection, m.Id) }).ToList(), - collection.MediaItems.OfType().Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin)) + collection.MediaItems.OfType() + .Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin, maybeEmby)) + .ToList(), + collection.MediaItems.OfType().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)) .ToList(), - collection.MediaItems.OfType().Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList(), collection.MediaItems.OfType() - .Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin)) + .Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby)) .ToList(), collection.MediaItems.OfType().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(), collection.MediaItems.OfType().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head())) .ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder }; - internal static ActorCardViewModel ProjectToViewModel(Actor actor, Option maybeJellyfin) + internal static ActorCardViewModel ProjectToViewModel( + Actor actor, + Option maybeJellyfin, + Option maybeEmby) { string artwork = actor.Artwork?.Path ?? string.Empty; @@ -105,6 +119,11 @@ namespace ErsatzTV.Application.MediaCards artwork = JellyfinUrl.ForArtwork(maybeJellyfin, artwork) .SetQueryParam("fillHeight", 440); } + else if (maybeEmby.IsSome && artwork.StartsWith("emby://")) + { + artwork = EmbyUrl.ForArtwork(maybeEmby, artwork) + .SetQueryParam("fillHeight", 440); + } return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork); } @@ -117,7 +136,10 @@ namespace ErsatzTV.Application.MediaCards private static string GetSeasonName(int number) => number == 0 ? "Specials" : $"Season {number}"; - private static string GetPoster(Metadata metadata, Option maybeJellyfin) + private static string GetPoster( + Metadata metadata, + Option maybeJellyfin, + Option maybeEmby) { string poster = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster)) .Match(a => a.Path, string.Empty); @@ -127,11 +149,19 @@ namespace ErsatzTV.Application.MediaCards poster = JellyfinUrl.ForArtwork(maybeJellyfin, poster) .SetQueryParam("fillHeight", 440); } + else if (maybeEmby.IsSome && poster.StartsWith("emby://")) + { + poster = EmbyUrl.ForArtwork(maybeEmby, poster) + .SetQueryParam("fillHeight", 440); + } return poster; } - private static string GetThumbnail(Metadata metadata, Option maybeJellyfin) + private static string GetThumbnail( + Metadata metadata, + Option maybeJellyfin, + Option maybeEmby) { string thumb = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail)) .Match(a => a.Path, string.Empty); @@ -141,6 +171,11 @@ namespace ErsatzTV.Application.MediaCards thumb = JellyfinUrl.ForArtwork(maybeJellyfin, thumb) .SetQueryParam("fillHeight", 220); } + else if (maybeEmby.IsSome && thumb.StartsWith("emby://")) + { + thumb = EmbyUrl.ForArtwork(maybeEmby, thumb) + .SetQueryParam("fillHeight", 220); + } return thumb; } diff --git a/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs index f167af9f7..88d023b86 100644 --- a/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs +++ b/ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs @@ -30,10 +30,13 @@ namespace ErsatzTV.Application.MediaCards.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + return await _collectionRepository .GetCollectionWithItemsUntracked(request.Id) .Map(c => c.ToEither(BaseError.New("Unable to load collection"))) - .MapT(c => ProjectToViewModel(c, maybeJellyfin)); + .MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby)); } } } diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs index d7bcd9489..ade1f9a6d 100644 --- a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs @@ -34,9 +34,12 @@ namespace ErsatzTV.Application.MediaCards.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + List results = await _televisionRepository .GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize) - .Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin)).ToList()); + .Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby)).ToList()); return new TelevisionEpisodeCardResultsViewModel(count, results); } diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs index e2bad20d5..86b862133 100644 --- a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs @@ -34,9 +34,12 @@ namespace ErsatzTV.Application.MediaCards.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + List results = await _televisionRepository .GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize) - .Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList()); + .Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList()); return new TelevisionSeasonCardResultsViewModel(count, results); } diff --git a/ErsatzTV.Application/MediaSources/RemoteMediaSourceViewModel.cs b/ErsatzTV.Application/MediaSources/RemoteMediaSourceViewModel.cs new file mode 100644 index 000000000..7ae53a49f --- /dev/null +++ b/ErsatzTV.Application/MediaSources/RemoteMediaSourceViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.MediaSources +{ + public record RemoteMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name); +} diff --git a/ErsatzTV.Application/Movies/Mapper.cs b/ErsatzTV.Application/Movies/Mapper.cs index 901ffa5e1..60f0e5be6 100644 --- a/ErsatzTV.Application/Movies/Mapper.cs +++ b/ErsatzTV.Application/Movies/Mapper.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; using ErsatzTV.Core.Jellyfin; using Flurl; using LanguageExt; @@ -12,7 +13,10 @@ namespace ErsatzTV.Application.Movies { internal static class Mapper { - internal static MovieViewModel ProjectToViewModel(Movie movie, Option maybeJellyfin) + internal static MovieViewModel ProjectToViewModel( + Movie movie, + Option maybeJellyfin, + Option maybeEmby) { MovieMetadata metadata = Optional(movie.MovieMetadata).Flatten().Head(); return new MovieViewModel( @@ -24,11 +28,11 @@ namespace ErsatzTV.Application.Movies metadata.Studios.Map(s => s.Name).ToList(), LanguagesForMovie(movie), metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id) - .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin)) + .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby)) .ToList()) { - Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin), - FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin) + Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), + FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby) }; } @@ -51,7 +55,8 @@ namespace ErsatzTV.Application.Movies private static string Artwork( Metadata metadata, ArtworkKind artworkKind, - Option maybeJellyfin) + Option maybeJellyfin, + Option maybeEmby) { string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) .Match(a => a.Path, string.Empty); @@ -66,6 +71,16 @@ namespace ErsatzTV.Application.Movies artwork = url; } + else if (maybeEmby.IsSome && artwork.StartsWith("emby://")) + { + Url url = EmbyUrl.ForArtwork(maybeEmby, artwork); + if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) + { + url.SetQueryParam("fillHeight", 440); + } + + artwork = url; + } return artwork; } diff --git a/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs index 1cb41388c..ff69074f3 100644 --- a/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs +++ b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs @@ -26,8 +26,11 @@ namespace ErsatzTV.Application.Movies.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + Option movie = await _movieRepository.GetMovie(request.Id); - return movie.Map(m => ProjectToViewModel(m, maybeJellyfin)); + return movie.Map(m => ProjectToViewModel(m, maybeJellyfin, maybeEmby)); } } } diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs index 9a1471400..1823ac336 100644 --- a/ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs @@ -41,9 +41,12 @@ namespace ErsatzTV.Application.Search.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + List items = await _movieRepository .GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList()) - .Map(list => list.Map(m => ProjectToViewModel(m, maybeJellyfin)).ToList()); + .Map(list => list.Map(m => ProjectToViewModel(m, maybeJellyfin, maybeEmby)).ToList()); return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); } diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs index 9caa61f1f..c95d97f6a 100644 --- a/ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs @@ -42,9 +42,12 @@ namespace ErsatzTV.Application.Search.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + List items = await _televisionRepository .GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList()) - .Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList()); + .Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList()); return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); } diff --git a/ErsatzTV.Application/Television/Mapper.cs b/ErsatzTV.Application/Television/Mapper.cs index 93034d019..929bc3f39 100644 --- a/ErsatzTV.Application/Television/Mapper.cs +++ b/ErsatzTV.Application/Television/Mapper.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using ErsatzTV.Application.MediaCards; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; using ErsatzTV.Core.Jellyfin; using Flurl; using LanguageExt; @@ -16,14 +17,15 @@ namespace ErsatzTV.Application.Television internal static TelevisionShowViewModel ProjectToViewModel( Show show, List languages, - Option maybeJellyfin) => + Option maybeJellyfin, + Option maybeEmby) => new( show.Id, show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty), - show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin)).IfNone(string.Empty), - show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin)).IfNone(string.Empty), + show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty), + show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List()), show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List()), show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()) @@ -32,21 +34,24 @@ namespace ErsatzTV.Application.Television show.ShowMetadata.HeadOrNone() .Map( m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id) - .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin)) + .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby)) .ToList()) .IfNone(new List())); internal static TelevisionSeasonViewModel ProjectToViewModel( Season season, - Option maybeJellyfin) => + Option maybeJellyfin, + Option maybeEmby) => new( season.Id, season.ShowId, season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty), season.Show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty), season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}", - season.SeasonMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin)).IfNone(string.Empty), - season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin)).IfNone(string.Empty)); + season.SeasonMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin, maybeEmby)) + .IfNone(string.Empty), + season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby)) + .IfNone(string.Empty)); internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) => new( @@ -55,21 +60,31 @@ namespace ErsatzTV.Application.Television episode.EpisodeNumber, episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty), episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty), - episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None)).IfNone(string.Empty)); + episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty)); - private static string GetPoster(Metadata metadata, Option maybeJellyfin) => - GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin); + private static string GetPoster( + Metadata metadata, + Option maybeJellyfin, + Option maybeEmby) => + GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby); - private static string GetFanArt(Metadata metadata, Option maybeJellyfin) => - GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin); + private static string GetFanArt( + Metadata metadata, + Option maybeJellyfin, + Option maybeEmby) => + GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby); - private static string GetThumbnail(Metadata metadata, Option maybeJellyfin) => - GetArtwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin); + private static string GetThumbnail( + Metadata metadata, + Option maybeJellyfin, + Option maybeEmby) => + GetArtwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin, maybeEmby); private static string GetArtwork( Metadata metadata, ArtworkKind artworkKind, - Option maybeJellyfin) + Option maybeJellyfin, + Option maybeEmby) { string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) .Match(a => a.Path, string.Empty); @@ -84,6 +99,16 @@ namespace ErsatzTV.Application.Television artwork = url; } + else if (maybeEmby.IsSome && artwork.StartsWith("emby://")) + { + Url url = EmbyUrl.ForArtwork(maybeEmby, artwork); + if (artworkKind == ArtworkKind.Poster) + { + url.SetQueryParam("fillHeight", 440); + } + + artwork = url; + } return artwork; } diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs index 436cf8534..efac5ff28 100644 --- a/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs @@ -29,8 +29,11 @@ namespace ErsatzTV.Application.Television.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + return await _televisionRepository.GetSeason(request.SeasonId) - .MapT(s => ProjectToViewModel(s, maybeJellyfin)); + .MapT(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)); } } } diff --git a/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs b/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs index 16736d410..10c46346a 100644 --- a/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs +++ b/ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs @@ -36,8 +36,11 @@ namespace ErsatzTV.Application.Television.Queries Option maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin() .Map(list => list.HeadOrNone()); + Option maybeEmby = await _mediaSourceRepository.GetAllEmby() + .Map(list => list.HeadOrNone()); + List languages = await _searchRepository.GetLanguagesForShow(show); - return ProjectToViewModel(show, languages, maybeJellyfin); + return ProjectToViewModel(show, languages, maybeJellyfin, maybeEmby); }, () => Task.FromResult(Option.None)); } diff --git a/ErsatzTV.Core/Domain/Library/EmbyLibrary.cs b/ErsatzTV.Core/Domain/Library/EmbyLibrary.cs new file mode 100644 index 000000000..13622f29b --- /dev/null +++ b/ErsatzTV.Core/Domain/Library/EmbyLibrary.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class EmbyLibrary : Library + { + public string ItemId { get; set; } + public bool ShouldSyncItems { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem/EmbyEpisode.cs b/ErsatzTV.Core/Domain/MediaItem/EmbyEpisode.cs new file mode 100644 index 000000000..482096070 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/EmbyEpisode.cs @@ -0,0 +1,11 @@ +using System.Diagnostics; + +namespace ErsatzTV.Core.Domain +{ + [DebuggerDisplay("{EpisodeMetadata[0].Title}")] + public class EmbyEpisode : Episode + { + public string ItemId { get; set; } + public string Etag { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem/EmbyMovie.cs b/ErsatzTV.Core/Domain/MediaItem/EmbyMovie.cs new file mode 100644 index 000000000..a82b6b15b --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/EmbyMovie.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class EmbyMovie : Movie + { + public string ItemId { get; set; } + public string Etag { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem/EmbySeason.cs b/ErsatzTV.Core/Domain/MediaItem/EmbySeason.cs new file mode 100644 index 000000000..cdd7e7039 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/EmbySeason.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class EmbySeason : Season + { + public string ItemId { get; set; } + public string Etag { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem/EmbyShow.cs b/ErsatzTV.Core/Domain/MediaItem/EmbyShow.cs new file mode 100644 index 000000000..0ba9dd3f0 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/EmbyShow.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class EmbyShow : Show + { + public string ItemId { get; set; } + public string Etag { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaSource/EmbyConnection.cs b/ErsatzTV.Core/Domain/MediaSource/EmbyConnection.cs new file mode 100644 index 000000000..77b6f1842 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSource/EmbyConnection.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Domain +{ + public class EmbyConnection + { + public int Id { get; set; } + public string Address { get; set; } + public int EmbyMediaSourceId { get; set; } + public EmbyMediaSource EmbyMediaSource { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaSource/EmbyMediaSource.cs b/ErsatzTV.Core/Domain/MediaSource/EmbyMediaSource.cs new file mode 100644 index 000000000..34404b057 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSource/EmbyMediaSource.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Core.Domain +{ + public class EmbyMediaSource : MediaSource + { + public string ServerName { get; set; } + public string OperatingSystem { get; set; } + public List Connections { get; set; } + public List PathReplacements { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaSource/EmbyPathReplacement.cs b/ErsatzTV.Core/Domain/MediaSource/EmbyPathReplacement.cs new file mode 100644 index 000000000..00ea50e4f --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSource/EmbyPathReplacement.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Core.Domain +{ + public class EmbyPathReplacement + { + public int Id { get; set; } + public string EmbyPath { get; set; } + public string LocalPath { get; set; } + public int EmbyMediaSourceId { get; set; } + public EmbyMediaSource EmbyMediaSource { get; set; } + } +} diff --git a/ErsatzTV.Core/Emby/EmbyItemEtag.cs b/ErsatzTV.Core/Emby/EmbyItemEtag.cs new file mode 100644 index 000000000..b4ecb7d9d --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbyItemEtag.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Emby +{ + public class EmbyItemEtag + { + public string ItemId { get; set; } + public string Etag { get; set; } + } +} diff --git a/ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs b/ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs new file mode 100644 index 000000000..312fa139e --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Metadata; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using MediatR; +using Microsoft.Extensions.Logging; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Core.Emby +{ + public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner + { + private readonly IEmbyApiClient _embyApiClient; + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IMediator _mediator; + private readonly IMovieRepository _movieRepository; + private readonly IEmbyPathReplacementService _pathReplacementService; + private readonly ISearchIndex _searchIndex; + private readonly ISearchRepository _searchRepository; + + public EmbyMovieLibraryScanner( + IEmbyApiClient embyApiClient, + ISearchIndex searchIndex, + IMediator mediator, + IMovieRepository movieRepository, + ISearchRepository searchRepository, + IEmbyPathReplacementService pathReplacementService, + IMediaSourceRepository mediaSourceRepository, + ILocalFileSystem localFileSystem, + ILocalStatisticsProvider localStatisticsProvider, + ILogger logger) + { + _embyApiClient = embyApiClient; + _searchIndex = searchIndex; + _mediator = mediator; + _movieRepository = movieRepository; + _searchRepository = searchRepository; + _pathReplacementService = pathReplacementService; + _mediaSourceRepository = mediaSourceRepository; + _localFileSystem = localFileSystem; + _localStatisticsProvider = localStatisticsProvider; + _logger = logger; + } + + public async Task> ScanLibrary( + string address, + string apiKey, + EmbyLibrary library, + string ffprobePath) + { + List existingMovies = await _movieRepository.GetExistingEmbyMovies(library); + + // TODO: maybe get quick list of item ids and etags from api to compare first + // TODO: paging? + + List pathReplacements = await _mediaSourceRepository + .GetEmbyPathReplacements(library.MediaSourceId); + + Either> maybeMovies = await _embyApiClient.GetMovieLibraryItems( + address, + apiKey, + library.MediaSourceId, + library.ItemId); + + await maybeMovies.Match( + async movies => + { + var validMovies = new List(); + foreach (EmbyMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title)) + { + string localPath = _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + movie.MediaVersions.Head().MediaFiles.Head().Path, + false); + + if (!_localFileSystem.FileExists(localPath)) + { + _logger.LogWarning("Skipping emby movie that does not exist at {Path}", localPath); + } + else + { + validMovies.Add(movie); + } + } + + foreach (EmbyMovie incoming in validMovies) + { + decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count; + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); + + Option maybeExisting = + existingMovies.Find(ie => ie.ItemId == incoming.ItemId); + + var updateStatistics = false; + + await maybeExisting.Match( + async existing => + { + try + { + if (existing.Etag == incoming.Etag) + { + // _logger.LogDebug( + // $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}"); + return; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for movie {Movie}", + incoming.MovieMetadata.Head().Title); + + updateStatistics = true; + incoming.LibraryPathId = library.Paths.Head().Id; + Option updated = await _movieRepository.UpdateEmby(incoming); + if (updated.IsSome) + { + await _searchIndex.UpdateItems( + _searchRepository, + new List { updated.ValueUnsafe() }); + } + } + catch (Exception ex) + { + updateStatistics = false; + _logger.LogError( + ex, + "Error updating movie {Movie}", + incoming.MovieMetadata.Head().Title); + } + }, + async () => + { + try + { + // _logger.LogDebug( + // $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}"); + + updateStatistics = true; + incoming.LibraryPathId = library.Paths.Head().Id; + if (await _movieRepository.AddEmby(incoming)) + { + await _searchIndex.AddItems( + _searchRepository, + new List { incoming }); + } + } + catch (Exception ex) + { + updateStatistics = false; + _logger.LogError( + ex, + "Error adding movie {Movie}", + incoming.MovieMetadata.Head().Title); + } + }); + + if (updateStatistics) + { + string localPath = _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + incoming.MediaVersions.Head().MediaFiles.Head().Path, + false); + + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); + Either refreshResult = + await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); + + await refreshResult.Match( + async _ => + { + Option updated = await _searchRepository.GetItemToIndex(incoming.Id); + if (updated.IsSome) + { + await _searchIndex.UpdateItems( + _searchRepository, + new List { updated.ValueUnsafe() }); + } + }, + error => + { + _logger.LogWarning( + "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", + "Statistics", + localPath, + error.Value); + + return Task.CompletedTask; + }); + } + + // TODO: figure out how to rebuild playlists + } + + var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList(); + var movieIds = existingMovies + .Filter(i => !incomingMovieIds.Contains(i.ItemId)) + .Map(m => m.ItemId) + .ToList(); + List ids = await _movieRepository.RemoveMissingEmbyMovies(library, movieIds); + await _searchIndex.RemoveItems(ids); + + await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); + _searchIndex.Commit(); + }, + error => + { + _logger.LogWarning( + "Error synchronizing emby library {Path}: {Error}", + library.Name, + error.Value); + + return Task.CompletedTask; + }); + + _searchIndex.Commit(); + return Unit.Default; + } + } +} diff --git a/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs b/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs new file mode 100644 index 000000000..b60c7591d --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Runtime; +using LanguageExt; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Emby +{ + public class EmbyPathReplacementService : IEmbyPathReplacementService + { + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IRuntimeInfo _runtimeInfo; + + public EmbyPathReplacementService( + IMediaSourceRepository mediaSourceRepository, + IRuntimeInfo runtimeInfo, + ILogger logger) + { + _mediaSourceRepository = mediaSourceRepository; + _runtimeInfo = runtimeInfo; + _logger = logger; + } + + public async Task GetReplacementEmbyPath(int libraryPathId, string path) + { + List replacements = + await _mediaSourceRepository.GetEmbyPathReplacementsByLibraryId(libraryPathId); + + return GetReplacementEmbyPath(replacements, path); + } + + public string GetReplacementEmbyPath( + List pathReplacements, + string path, + bool log = true) + { + Option maybeReplacement = pathReplacements + .SingleOrDefault( + r => + { + string separatorChar = IsWindows(r.EmbyMediaSource) ? @"\" : @"/"; + string prefix = r.EmbyPath.EndsWith(separatorChar) + ? r.EmbyPath + : r.EmbyPath + separatorChar; + return path.StartsWith(prefix); + }); + + return maybeReplacement.Match( + replacement => + { + string finalPath = path.Replace(replacement.EmbyPath, replacement.LocalPath); + if (IsWindows(replacement.EmbyMediaSource) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows)) + { + finalPath = finalPath.Replace(@"\", @"/"); + } + else if (!IsWindows(replacement.EmbyMediaSource) && + _runtimeInfo.IsOSPlatform(OSPlatform.Windows)) + { + finalPath = finalPath.Replace(@"/", @"\"); + } + + if (log) + { + _logger.LogDebug( + "Replacing emby path {EmbyPath} with {LocalPath} resulting in {FinalPath}", + replacement.EmbyPath, + replacement.LocalPath, + finalPath); + } + + return finalPath; + }, + () => path); + } + + private static bool IsWindows(EmbyMediaSource embyMediaSource) => + embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows"); + } +} diff --git a/ErsatzTV.Core/Emby/EmbySecrets.cs b/ErsatzTV.Core/Emby/EmbySecrets.cs new file mode 100644 index 000000000..9650fb00b --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbySecrets.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core.MediaSources; + +namespace ErsatzTV.Core.Emby +{ + public class EmbySecrets : RemoteMediaSourceSecrets + { + } +} diff --git a/ErsatzTV.Core/Emby/EmbyServerInformation.cs b/ErsatzTV.Core/Emby/EmbyServerInformation.cs new file mode 100644 index 000000000..81d08d575 --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbyServerInformation.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Emby +{ + public record EmbyServerInformation(string ServerName, string OperatingSystem); +} diff --git a/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs b/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs new file mode 100644 index 000000000..4d7bcd2ac --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs @@ -0,0 +1,416 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Metadata; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using MediatR; +using Microsoft.Extensions.Logging; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Core.Emby +{ + public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner + { + private readonly IEmbyApiClient _embyApiClient; + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IMediator _mediator; + private readonly IEmbyPathReplacementService _pathReplacementService; + private readonly ISearchIndex _searchIndex; + private readonly ISearchRepository _searchRepository; + private readonly IEmbyTelevisionRepository _televisionRepository; + + public EmbyTelevisionLibraryScanner( + IEmbyApiClient embyApiClient, + IMediaSourceRepository mediaSourceRepository, + IEmbyTelevisionRepository televisionRepository, + ISearchIndex searchIndex, + ISearchRepository searchRepository, + IEmbyPathReplacementService pathReplacementService, + ILocalFileSystem localFileSystem, + ILocalStatisticsProvider localStatisticsProvider, + IMediator mediator, + ILogger logger) + { + _embyApiClient = embyApiClient; + _mediaSourceRepository = mediaSourceRepository; + _televisionRepository = televisionRepository; + _searchIndex = searchIndex; + _searchRepository = searchRepository; + _pathReplacementService = pathReplacementService; + _localFileSystem = localFileSystem; + _localStatisticsProvider = localStatisticsProvider; + _mediator = mediator; + _logger = logger; + } + + public async Task> ScanLibrary( + string address, + string apiKey, + EmbyLibrary library, + string ffprobePath) + { + List existingShows = await _televisionRepository.GetExistingShows(library); + + // TODO: maybe get quick list of item ids and etags from api to compare first + // TODO: paging? + + List pathReplacements = await _mediaSourceRepository + .GetEmbyPathReplacements(library.MediaSourceId); + + Either> maybeShows = await _embyApiClient.GetShowLibraryItems( + address, + apiKey, + library.MediaSourceId, + library.ItemId); + + await maybeShows.Match( + async shows => + { + await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows); + + var incomingShowIds = shows.Map(s => s.ItemId).ToList(); + var showIds = existingShows + .Filter(i => !incomingShowIds.Contains(i.ItemId)) + .Map(m => m.ItemId) + .ToList(); + List missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); + await _searchIndex.RemoveItems(missingShowIds); + + await _televisionRepository.DeleteEmptySeasons(library); + List emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); + await _searchIndex.RemoveItems(emptyShowIds); + + await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); + _searchIndex.Commit(); + }, + error => + { + _logger.LogWarning( + "Error synchronizing emby library {Path}: {Error}", + library.Name, + error.Value); + + return Task.CompletedTask; + }); + + return Unit.Default; + } + + private async Task ProcessShows( + string address, + string apiKey, + EmbyLibrary library, + string ffprobePath, + List pathReplacements, + List existingShows, + List shows) + { + foreach (EmbyShow incoming in shows.OrderBy(s => s.ShowMetadata.Head().Title)) + { + decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count; + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); + + var changed = false; + + Option maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId); + await maybeExisting.Match( + async existing => + { + if (existing.Etag == incoming.Etag) + { + return; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for show {Show}", + incoming.ShowMetadata.Head().Title); + + changed = true; + incoming.LibraryPathId = library.Paths.Head().Id; + + Option updated = await _televisionRepository.Update(incoming); + if (updated.IsSome) + { + await _searchIndex.UpdateItems( + _searchRepository, + new List { updated.ValueUnsafe() }); + } + }, + async () => + { + changed = true; + incoming.LibraryPathId = library.Paths.Head().Id; + + // _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title); + + if (await _televisionRepository.AddShow(incoming)) + { + await _searchIndex.AddItems(_searchRepository, new List { incoming }); + } + }); + + if (changed) + { + List existingSeasons = + await _televisionRepository.GetExistingSeasons(library, incoming.ItemId); + + Either> maybeSeasons = + await _embyApiClient.GetSeasonLibraryItems( + address, + apiKey, + library.MediaSourceId, + incoming.ItemId); + + await maybeSeasons.Match( + async seasons => + { + await ProcessSeasons( + address, + apiKey, + library, + ffprobePath, + pathReplacements, + incoming, + existingSeasons, + seasons); + + await _searchIndex.UpdateItems(_searchRepository, new List { incoming }); + + var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); + var seasonIds = existingSeasons + .Filter(i => !incomingSeasonIds.Contains(i.ItemId)) + .Map(m => m.ItemId) + .ToList(); + await _televisionRepository.RemoveMissingSeasons(library, seasonIds); + }, + error => + { + _logger.LogWarning( + "Error synchronizing emby library {Path}: {Error}", + library.Name, + error.Value); + + return Task.CompletedTask; + }); + } + } + } + + private async Task ProcessSeasons( + string address, + string apiKey, + EmbyLibrary library, + string ffprobePath, + List pathReplacements, + EmbyShow show, + List existingSeasons, + List seasons) + { + foreach (EmbySeason incoming in seasons) + { + var changed = false; + + Option maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); + await maybeExisting.Match( + async existing => + { + if (existing.Etag == incoming.Etag) + { + return; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for show {Show} season {Season}", + show.ShowMetadata.Head().Title, + incoming.SeasonMetadata.Head().Title); + + changed = true; + incoming.ShowId = show.Id; + incoming.LibraryPathId = library.Paths.Head().Id; + + await _televisionRepository.Update(incoming); + }, + async () => + { + changed = true; + incoming.ShowId = show.Id; + incoming.LibraryPathId = library.Paths.Head().Id; + + _logger.LogDebug( + "INSERT: Item id is new for show {Show} season {Season}", + show.ShowMetadata.Head().Title, + incoming.SeasonMetadata.Head().Title); + + await _televisionRepository.AddSeason(incoming); + }); + + if (changed) + { + List existingEpisodes = + await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); + + Either> maybeEpisodes = + await _embyApiClient.GetEpisodeLibraryItems( + address, + apiKey, + library.MediaSourceId, + incoming.ItemId); + + await maybeEpisodes.Match( + async episodes => + { + var validEpisodes = new List(); + foreach (EmbyEpisode episode in episodes) + { + string localPath = _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + episode.MediaVersions.Head().MediaFiles.Head().Path, + false); + + if (!_localFileSystem.FileExists(localPath)) + { + _logger.LogWarning( + "Skipping emby episode that does not exist at {Path}", + localPath); + } + else + { + validEpisodes.Add(episode); + } + } + + await ProcessEpisodes( + show.ShowMetadata.Head().Title, + incoming.SeasonMetadata.Head().Title, + library, + ffprobePath, + pathReplacements, + incoming, + existingEpisodes, + validEpisodes); + + var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList(); + var episodeIds = existingEpisodes + .Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) + .Map(m => m.ItemId) + .ToList(); + await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); + }, + error => + { + _logger.LogWarning( + "Error synchronizing emby library {Path}: {Error}", + library.Name, + error.Value); + + return Task.CompletedTask; + }); + } + } + } + + private async Task ProcessEpisodes( + string showName, + string seasonName, + EmbyLibrary library, + string ffprobePath, + List pathReplacements, + EmbySeason season, + List existingEpisodes, + List episodes) + { + foreach (EmbyEpisode incoming in episodes) + { + var updateStatistics = false; + + Option maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); + await maybeExisting.Match( + async existing => + { + try + { + if (existing.Etag == incoming.Etag) + { + return; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", + showName, + seasonName, + incoming.EpisodeNumber); + + updateStatistics = true; + incoming.SeasonId = season.Id; + incoming.LibraryPathId = library.Paths.Head().Id; + + await _televisionRepository.Update(incoming); + } + catch (Exception ex) + { + updateStatistics = false; + _logger.LogError( + ex, + "Error updating episode {Path}", + incoming.MediaVersions.Head().MediaFiles.Head().Path); + } + }, + async () => + { + try + { + updateStatistics = true; + incoming.SeasonId = season.Id; + incoming.LibraryPathId = library.Paths.Head().Id; + + _logger.LogDebug( + "INSERT: Item id is new for show {Show} season {Season} episode {Episode}", + showName, + seasonName, + incoming.EpisodeNumber); + + await _televisionRepository.AddEpisode(incoming); + } + catch (Exception ex) + { + updateStatistics = false; + _logger.LogError( + ex, + "Error adding episode {Path}", + incoming.MediaVersions.Head().MediaFiles.Head().Path); + } + }); + + if (updateStatistics) + { + string localPath = _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + incoming.MediaVersions.Head().MediaFiles.Head().Path, + false); + + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); + Either refreshResult = + await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); + + refreshResult.Match( + _ => { }, + error => _logger.LogWarning( + "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", + "Statistics", + localPath, + error.Value)); + } + } + } + } +} diff --git a/ErsatzTV.Core/Emby/EmbyUrl.cs b/ErsatzTV.Core/Emby/EmbyUrl.cs new file mode 100644 index 000000000..9e7907141 --- /dev/null +++ b/ErsatzTV.Core/Emby/EmbyUrl.cs @@ -0,0 +1,31 @@ +using ErsatzTV.Core.Domain; +using Flurl; +using LanguageExt; + +namespace ErsatzTV.Core.Emby +{ + public static class EmbyUrl + { + public static Url ForArtwork(Option maybeEmby, string artwork) + { + string address = maybeEmby.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address)) + .Flatten() + .IfNone("emby://"); + + string[] split = artwork.Replace("emby://", string.Empty).Split('?'); + if (split.Length != 2) + { + return artwork; + } + + string pathSegment = split[0]; + QueryParamCollection query = Url.ParseQueryParams(split[1]); + + Url x = Url.Parse(address) + .AppendPathSegment(pathSegment) + .SetQueryParams(query); + + return x; + } + } +} diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index aa6495717..7375bc522 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -19,6 +19,7 @@ namespace ErsatzTV.Core public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json"); public static readonly string JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json"); + public static readonly string EmbySecretsPath = Path.Combine(AppDataFolder, "emby-secrets.json"); public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports"); public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index"); diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs new file mode 100644 index 000000000..c12c15f0a --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Emby +{ + public interface IEmbyApiClient + { + Task> GetServerInformation(string address, string apiKey); + Task>> GetLibraries(string address, string apiKey); + + Task>> GetMovieLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string libraryId); + + Task>> GetShowLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string libraryId); + + Task>> GetSeasonLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string showId); + + Task>> GetEpisodeLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string seasonId); + } +} diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs new file mode 100644 index 000000000..b547a5975 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Emby +{ + public interface IEmbyMovieLibraryScanner + { + Task> ScanLibrary( + string address, + string apiKey, + EmbyLibrary library, + string ffprobePath); + } +} diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs new file mode 100644 index 000000000..31a4b5864 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Emby +{ + public interface IEmbyPathReplacementService + { + Task GetReplacementEmbyPath(int libraryPathId, string path); + string GetReplacementEmbyPath(List pathReplacements, string path, bool log = true); + } +} diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbySecretStore.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbySecretStore.cs new file mode 100644 index 000000000..0c934e75d --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbySecretStore.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.MediaSources; + +namespace ErsatzTV.Core.Interfaces.Emby +{ + public interface IEmbySecretStore : IRemoteMediaSourceSecretStore + { + } +} diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs new file mode 100644 index 000000000..7a2453a17 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Emby +{ + public interface IEmbyTelevisionLibraryScanner + { + Task> ScanLibrary( + string address, + string apiKey, + EmbyLibrary library, + string ffprobePath); + } +} diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinSecretStore.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinSecretStore.cs index 253078e76..1339cb81c 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinSecretStore.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinSecretStore.cs @@ -1,13 +1,9 @@ -using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.MediaSources; using ErsatzTV.Core.Jellyfin; -using LanguageExt; namespace ErsatzTV.Core.Interfaces.Jellyfin { - public interface IJellyfinSecretStore + public interface IJellyfinSecretStore : IRemoteMediaSourceSecretStore { - Task DeleteAll(); - Task ReadSecrets(); - Task SaveSecrets(JellyfinSecrets jellyfinSecrets); } } diff --git a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs index 9b04a6ca8..b4162c3c5 100644 --- a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs +++ b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs @@ -6,14 +6,14 @@ namespace ErsatzTV.Core.Interfaces.Locking { event EventHandler OnLibraryChanged; event EventHandler OnPlexChanged; - event EventHandler OnJellyfinChanged; + event EventHandler OnRemoteMediaSourceChanged; bool LockLibrary(int libraryId); bool UnlockLibrary(int libraryId); bool IsLibraryLocked(int libraryId); bool LockPlex(); bool UnlockPlex(); bool IsPlexLocked(); - bool LockJellyfin(); - bool UnlockJellyfin(); + bool LockRemoteMediaSource(); + bool UnlockRemoteMediaSource(); } } diff --git a/ErsatzTV.Core/Interfaces/MediaSources/IRemoteMediaSourceSecretStore.cs b/ErsatzTV.Core/Interfaces/MediaSources/IRemoteMediaSourceSecretStore.cs new file mode 100644 index 000000000..5afe030a7 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/MediaSources/IRemoteMediaSourceSecretStore.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.MediaSources +{ + public interface IRemoteMediaSourceSecretStore + { + Task DeleteAll(); + Task ReadSecrets(); + Task SaveSecrets(TSecrets jellyfinSecrets); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs new file mode 100644 index 000000000..8a2a8a645 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IEmbyTelevisionRepository + { + Task> GetExistingShows(EmbyLibrary library); + Task> GetExistingSeasons(EmbyLibrary library, string showItemId); + Task> GetExistingEpisodes(EmbyLibrary library, string seasonItemId); + Task AddShow(EmbyShow show); + Task> Update(EmbyShow show); + Task AddSeason(EmbySeason season); + Task Update(EmbySeason season); + Task AddEpisode(EmbyEpisode episode); + Task Update(EmbyEpisode episode); + Task> RemoveMissingShows(EmbyLibrary library, List showIds); + Task RemoveMissingSeasons(EmbyLibrary library, List seasonIds); + Task RemoveMissingEpisodes(EmbyLibrary library, List episodeIds); + Task DeleteEmptySeasons(EmbyLibrary library); + Task> DeleteEmptyShows(EmbyLibrary library); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs index 2f4e8c893..aac081b8a 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs @@ -37,6 +37,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories List toAdd, List toDelete); + Task UpdateLibraries( + int embyMediaSourceId, + List toAdd, + List toDelete); + Task UpdatePathReplacements( int plexMediaSourceId, List toAdd, @@ -68,5 +73,24 @@ namespace ErsatzTV.Core.Interfaces.Repositories List toDelete); Task> DeleteAllJellyfin(); + + Task UpsertEmby(string address, string serverName, string operatingSystem); + Task> GetAllEmby(); + Task> GetEmby(int id); + Task> GetEmbyByLibraryId(int embyLibraryId); + Task> GetEmbyLibrary(int embyLibraryId); + Task> GetEmbyLibraries(int embyMediaSourceId); + Task> GetEmbyPathReplacements(int embyMediaSourceId); + Task> GetEmbyPathReplacementsByLibraryId(int embyLibraryPathId); + + Task UpdatePathReplacements( + int embyMediaSourceId, + List toAdd, + List toUpdate, + List toDelete); + + Task> DeleteAllEmby(); + Task EnableEmbyLibrarySync(IEnumerable libraryIds); + Task> DisableEmbyLibrarySync(List libraryIds); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index 9f36a3dff..926b891d6 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using LanguageExt; @@ -28,5 +29,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> RemoveMissingJellyfinMovies(JellyfinLibrary library, List movieIds); Task AddJellyfin(JellyfinMovie movie); Task> UpdateJellyfin(JellyfinMovie movie); + Task> GetExistingEmbyMovies(EmbyLibrary library); + Task> RemoveMissingEmbyMovies(EmbyLibrary library, List movieIds); + Task AddEmby(EmbyMovie movie); + Task> UpdateEmby(EmbyMovie movie); } } diff --git a/ErsatzTV.Core/Jellyfin/JellyfinSecrets.cs b/ErsatzTV.Core/Jellyfin/JellyfinSecrets.cs index 7f1a5f395..a953103b1 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinSecrets.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinSecrets.cs @@ -1,8 +1,8 @@ -namespace ErsatzTV.Core.Jellyfin +using ErsatzTV.Core.MediaSources; + +namespace ErsatzTV.Core.Jellyfin { - public class JellyfinSecrets + public class JellyfinSecrets : RemoteMediaSourceSecrets { - public string Address { get; set; } - public string ApiKey { get; set; } } } diff --git a/ErsatzTV.Core/MediaSources/RemoteMediaSourceSecrets.cs b/ErsatzTV.Core/MediaSources/RemoteMediaSourceSecrets.cs new file mode 100644 index 000000000..0368409a6 --- /dev/null +++ b/ErsatzTV.Core/MediaSources/RemoteMediaSourceSecrets.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.MediaSources +{ + public class RemoteMediaSourceSecrets + { + public string Address { get; set; } + public string ApiKey { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/Library/EmbyLibraryConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/Library/EmbyLibraryConfiguration.cs new file mode 100644 index 000000000..64dfffa6e --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/Library/EmbyLibraryConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbyLibraryConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("EmbyLibrary"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyEpisodeConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyEpisodeConfiguration.cs new file mode 100644 index 000000000..af597b817 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyEpisodeConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbyEpisodeConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("EmbyEpisode"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyMovieConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyMovieConfiguration.cs new file mode 100644 index 000000000..83f8f720f --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyMovieConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbyMovieConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("EmbyMovie"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbySeasonConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbySeasonConfiguration.cs new file mode 100644 index 000000000..bf767b275 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbySeasonConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbySeasonConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("EmbySeason"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyShowConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyShowConfiguration.cs new file mode 100644 index 000000000..07f0c467f --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/EmbyShowConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbyShowConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("EmbyShow"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyConnectionConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyConnectionConfiguration.cs new file mode 100644 index 000000000..38f7ec1d9 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyConnectionConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbyConnectionConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("EmbyConnection"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyMediaSourceConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyMediaSourceConfiguration.cs new file mode 100644 index 000000000..6fc41ae77 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyMediaSourceConfiguration.cs @@ -0,0 +1,24 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbyMediaSourceConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("EmbyMediaSource"); + + builder.HasMany(s => s.Connections) + .WithOne(c => c.EmbyMediaSource) + .HasForeignKey(c => c.EmbyMediaSourceId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(s => s.PathReplacements) + .WithOne(r => r.EmbyMediaSource) + .HasForeignKey(r => r.EmbyMediaSourceId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyPathReplacementConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyPathReplacementConfiguration.cs new file mode 100644 index 000000000..549450237 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/EmbyPathReplacementConfiguration.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class EmbyPathReplacementConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => + builder.ToTable("EmbyPathReplacement"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs new file mode 100644 index 000000000..166cde12e --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class EmbyTelevisionRepository : IEmbyTelevisionRepository + { + private readonly IDbConnection _dbConnection; + private readonly IDbContextFactory _dbContextFactory; + + public EmbyTelevisionRepository(IDbConnection dbConnection, IDbContextFactory dbContextFactory) + { + _dbConnection = dbConnection; + _dbContextFactory = dbContextFactory; + } + + public Task> GetExistingShows(EmbyLibrary library) => + _dbConnection.QueryAsync( + @"SELECT ItemId, Etag FROM EmbyShow + INNER JOIN Show S on EmbyShow.Id = S.Id + INNER JOIN MediaItem MI on S.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { LibraryId = library.Id }) + .Map(result => result.ToList()); + + public Task> GetExistingSeasons(EmbyLibrary library, string showItemId) => + _dbConnection.QueryAsync( + @"SELECT EmbySeason.ItemId, EmbySeason.Etag FROM EmbySeason + INNER JOIN Season S on EmbySeason.Id = S.Id + INNER JOIN MediaItem MI on S.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + INNER JOIN Show S2 on S.ShowId = S2.Id + INNER JOIN EmbyShow JS on S2.Id = JS.Id + WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @ShowItemId", + new { LibraryId = library.Id, ShowItemId = showItemId }) + .Map(result => result.ToList()); + + public Task> GetExistingEpisodes(EmbyLibrary library, string seasonItemId) => + _dbConnection.QueryAsync( + @"SELECT EmbyEpisode.ItemId, EmbyEpisode.Etag FROM EmbyEpisode + INNER JOIN Episode E on EmbyEpisode.Id = E.Id + INNER JOIN MediaItem MI on E.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + INNER JOIN Season S2 on E.SeasonId = S2.Id + INNER JOIN EmbySeason JS on S2.Id = JS.Id + WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @SeasonItemId", + new { LibraryId = library.Id, SeasonItemId = seasonItemId }) + .Map(result => result.ToList()); + + public async Task AddShow(EmbyShow show) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await dbContext.AddAsync(show); + if (await dbContext.SaveChangesAsync() <= 0) + { + return false; + } + + await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return true; + } + + public async Task> Update(EmbyShow show) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await dbContext.EmbyShows + .Include(m => m.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(m => m.ShowMetadata) + .ThenInclude(mm => mm.Genres) + .Include(m => m.ShowMetadata) + .ThenInclude(mm => mm.Tags) + .Include(m => m.ShowMetadata) + .ThenInclude(mm => mm.Studios) + .Include(m => m.ShowMetadata) + .ThenInclude(mm => mm.Actors) + .Include(m => m.ShowMetadata) + .ThenInclude(mm => mm.Artwork) + .Filter(m => m.ItemId == show.ItemId) + .OrderBy(m => m.ItemId) + .SingleOrDefaultAsync(); + + if (maybeExisting.IsSome) + { + EmbyShow existing = maybeExisting.ValueUnsafe(); + + // library path is used for search indexing later + show.LibraryPath = existing.LibraryPath; + show.Id = existing.Id; + + existing.Etag = show.Etag; + + // metadata + ShowMetadata metadata = existing.ShowMetadata.Head(); + ShowMetadata incomingMetadata = show.ShowMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.Tagline = incomingMetadata.Tagline; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + + // genres + foreach (Genre genre in metadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Remove(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Add(genre); + } + + // tags + foreach (Tag tag in metadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Remove(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Add(tag); + } + + // studios + foreach (Studio studio in metadata.Studios + .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Remove(studio); + } + + foreach (Studio studio in incomingMetadata.Studios + .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Add(studio); + } + + // actors + foreach (Actor actor in metadata.Actors + .Filter( + a => incomingMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + metadata.Actors.Remove(actor); + } + + foreach (Actor actor in incomingMetadata.Actors + .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + metadata.Actors.Add(actor); + } + + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + } + + await dbContext.SaveChangesAsync(); + + return maybeExisting; + } + + public async Task AddSeason(EmbySeason season) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await dbContext.AddAsync(season); + if (await dbContext.SaveChangesAsync() <= 0) + { + return false; + } + + await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return true; + } + + public async Task Update(EmbySeason season) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await dbContext.EmbySeasons + .Include(m => m.LibraryPath) + .Include(m => m.SeasonMetadata) + .ThenInclude(mm => mm.Artwork) + .Filter(m => m.ItemId == season.ItemId) + .OrderBy(m => m.ItemId) + .SingleOrDefaultAsync(); + + if (maybeExisting.IsSome) + { + EmbySeason existing = maybeExisting.ValueUnsafe(); + + // library path is used for search indexing later + season.LibraryPath = existing.LibraryPath; + season.Id = existing.Id; + + existing.Etag = season.Etag; + existing.SeasonNumber = season.SeasonNumber; + + // metadata + SeasonMetadata metadata = existing.SeasonMetadata.Head(); + SeasonMetadata incomingMetadata = season.SeasonMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Year = incomingMetadata.Year; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + } + + await dbContext.SaveChangesAsync(); + + return Unit.Default; + } + + public async Task AddEpisode(EmbyEpisode episode) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await dbContext.AddAsync(episode); + if (await dbContext.SaveChangesAsync() <= 0) + { + return false; + } + + await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return true; + } + + public async Task Update(EmbyEpisode episode) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await dbContext.EmbyEpisodes + .Include(m => m.LibraryPath) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(m => m.EpisodeMetadata) + .ThenInclude(mm => mm.Artwork) + .Filter(m => m.ItemId == episode.ItemId) + .OrderBy(m => m.ItemId) + .SingleOrDefaultAsync(); + + if (maybeExisting.IsSome) + { + EmbyEpisode existing = maybeExisting.ValueUnsafe(); + + // library path is used for search indexing later + episode.LibraryPath = existing.LibraryPath; + episode.Id = existing.Id; + + existing.Etag = episode.Etag; + existing.EpisodeNumber = episode.EpisodeNumber; + + // metadata + EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); + EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // thumbnail + Artwork incomingThumbnail = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (incomingThumbnail != null) + { + Artwork thumbnail = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (thumbnail == null) + { + thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; + metadata.Artwork.Add(thumbnail); + } + + thumbnail.Path = incomingThumbnail.Path; + thumbnail.DateAdded = incomingThumbnail.DateAdded; + thumbnail.DateUpdated = incomingThumbnail.DateUpdated; + } + + // version + MediaVersion version = existing.MediaVersions.Head(); + MediaVersion incomingVersion = episode.MediaVersions.Head(); + version.Name = incomingVersion.Name; + version.DateAdded = incomingVersion.DateAdded; + + // media file + MediaFile file = version.MediaFiles.Head(); + MediaFile incomingFile = incomingVersion.MediaFiles.Head(); + file.Path = incomingFile.Path; + } + + await dbContext.SaveChangesAsync(); + + return Unit.Default; + } + + public async Task> RemoveMissingShows(EmbyLibrary library, List showIds) + { + List ids = await _dbConnection.QueryAsync( + @"SELECT m.Id FROM MediaItem m + INNER JOIN EmbyShow js ON js.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds", + new { LibraryId = library.Id, ShowIds = showIds }).Map(result => result.ToList()); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyShow js ON js.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds)", + new { LibraryId = library.Id, ShowIds = showIds }); + + return ids; + } + + public Task RemoveMissingSeasons(EmbyLibrary library, List seasonIds) => + _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbySeason js ON js.Id = m.Id + INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)", + new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit(); + + public Task RemoveMissingEpisodes(EmbyLibrary library, List episodeIds) => + _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyEpisode je ON je.Id = m.Id + INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds)", + new { LibraryId = library.Id, EpisodeIds = episodeIds }).ToUnit(); + + public async Task DeleteEmptySeasons(EmbyLibrary library) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + List seasons = await dbContext.EmbySeasons + .Filter(s => s.LibraryPath.LibraryId == library.Id) + .Filter(s => s.Episodes.Count == 0) + .ToListAsync(); + dbContext.Seasons.RemoveRange(seasons); + await dbContext.SaveChangesAsync(); + return Unit.Default; + } + + public async Task> DeleteEmptyShows(EmbyLibrary library) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + List shows = await dbContext.EmbyShows + .Filter(s => s.LibraryPath.LibraryId == library.Id) + .Filter(s => s.Seasons.Count == 0) + .ToListAsync(); + var ids = shows.Map(s => s.Id).ToList(); + dbContext.Shows.RemoveRange(shows); + await dbContext.SaveChangesAsync(); + return ids; + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index e81cf6089..4167891b4 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -260,6 +260,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return Unit.Default; } + public async Task UpdateLibraries( + int embyMediaSourceId, + List toAdd, + List toDelete) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + + foreach (EmbyLibrary add in toAdd) + { + add.MediaSourceId = embyMediaSourceId; + dbContext.Entry(add).State = EntityState.Added; + foreach (LibraryPath path in add.Paths) + { + dbContext.Entry(path).State = EntityState.Added; + } + } + + foreach (EmbyLibrary delete in toDelete) + { + dbContext.Entry(delete).State = EntityState.Deleted; + } + + await dbContext.SaveChangesAsync(); + + return Unit.Default; + } + public async Task UpdatePathReplacements( int plexMediaSourceId, List toAdd, @@ -656,17 +683,294 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await using TvContext context = _dbContextFactory.CreateDbContext(); List allMediaSources = await context.JellyfinMediaSources.ToListAsync(); + var mediaSourceIds = allMediaSources.Map(ms => ms.Id).ToList(); context.JellyfinMediaSources.RemoveRange(allMediaSources); - List allJellyfinLibraries = await context.JellyfinLibraries.ToListAsync(); + List allJellyfinLibraries = await context.JellyfinLibraries + .Where(l => mediaSourceIds.Contains(l.MediaSourceId)) + .ToListAsync(); + var libraryIds = allJellyfinLibraries.Map(l => l.Id).ToList(); context.JellyfinLibraries.RemoveRange(allJellyfinLibraries); - List movieIds = await context.JellyfinMovies.Map(pm => pm.Id).ToListAsync(); - List showIds = await context.JellyfinShows.Map(ps => ps.Id).ToListAsync(); + List movieIds = await context.JellyfinMovies + .Where(m => libraryIds.Contains(m.LibraryPath.LibraryId)) + .Map(pm => pm.Id) + .ToListAsync(); + + List showIds = await context.JellyfinShows + .Where(m => libraryIds.Contains(m.LibraryPath.LibraryId)) + .Map(ps => ps.Id) + .ToListAsync(); await context.SaveChangesAsync(); return movieIds.Append(showIds).ToList(); } + + public async Task UpsertEmby(string address, string serverName, string operatingSystem) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Option maybeExisting = dbContext.EmbyMediaSources + .Include(ms => ms.Connections) + .OrderBy(ms => ms.Id) + .HeadOrNone(); + + return await maybeExisting.Match( + async embyMediaSource => + { + if (!embyMediaSource.Connections.Any()) + { + embyMediaSource.Connections.Add(new EmbyConnection { Address = address }); + } + else if (embyMediaSource.Connections.Head().Address != address) + { + embyMediaSource.Connections.Head().Address = address; + } + + if (embyMediaSource.ServerName != serverName) + { + embyMediaSource.ServerName = serverName; + } + + if (embyMediaSource.OperatingSystem != operatingSystem) + { + embyMediaSource.OperatingSystem = operatingSystem; + } + + await dbContext.SaveChangesAsync(); + + return Unit.Default; + }, + async () => + { + var mediaSource = new EmbyMediaSource + { + ServerName = serverName, + OperatingSystem = operatingSystem, + Connections = new List + { + new() { Address = address } + }, + PathReplacements = new List() + }; + + await dbContext.AddAsync(mediaSource); + await dbContext.SaveChangesAsync(); + + return Unit.Default; + }); + } + + public Task> GetAllEmby() + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.EmbyMediaSources + .Include(p => p.Connections) + .ToListAsync(); + } + + public Task> GetEmby(int id) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.EmbyMediaSources + .Include(p => p.Connections) + .Include(p => p.Libraries) + .Include(p => p.PathReplacements) + .OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579 + .SingleOrDefaultAsync(p => p.Id == id) + .Map(Optional); + } + + public async Task> GetEmbyByLibraryId(int embyLibraryId) + { + int? id = await _dbConnection.QuerySingleAsync( + @"SELECT L.MediaSourceId FROM Library L + INNER JOIN EmbyLibrary PL on L.Id = PL.Id + WHERE L.Id = @EmbyLibraryId", + new { EmbyLibraryId = embyLibraryId }); + + await using TvContext context = _dbContextFactory.CreateDbContext(); + return await context.EmbyMediaSources + .Include(p => p.Connections) + .Include(p => p.Libraries) + .OrderBy(p => p.Id) + .SingleOrDefaultAsync(p => p.Id == id) + .Map(Optional); + } + + public Task> GetEmbyLibrary(int embyLibraryId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.EmbyLibraries + .Include(l => l.Paths) + .OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579 + .SingleOrDefaultAsync(l => l.Id == embyLibraryId) + .Map(Optional); + } + + public Task> GetEmbyLibraries(int embyMediaSourceId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.EmbyLibraries + .Filter(l => l.MediaSourceId == embyMediaSourceId) + .ToListAsync(); + } + + public Task> GetEmbyPathReplacements(int embyMediaSourceId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.EmbyPathReplacements + .Filter(r => r.EmbyMediaSourceId == embyMediaSourceId) + .Include(jpr => jpr.EmbyMediaSource) + .ToListAsync(); + } + + public Task> GetEmbyPathReplacementsByLibraryId(int embyLibraryPathId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.EmbyPathReplacements + .FromSqlRaw( + @"select epr.* from LibraryPath lp + inner join EmbyLibrary el ON el.Id = lp.LibraryId + inner join Library l ON l.Id = el.Id + inner join EmbyPathReplacement epr on epr.EmbyMediaSourceId = l.MediaSourceId + where lp.Id = {0}", + embyLibraryPathId) + .Include(jpr => jpr.EmbyMediaSource) + .ToListAsync(); + } + + public async Task UpdatePathReplacements( + int embyMediaSourceId, + List toAdd, + List toUpdate, + List toDelete) + { + foreach (EmbyPathReplacement add in toAdd) + { + await _dbConnection.ExecuteAsync( + @"INSERT INTO EmbyPathReplacement + (EmbyPath, LocalPath, EmbyMediaSourceId) + VALUES (@EmbyPath, @LocalPath, @EmbyMediaSourceId)", + new { add.EmbyPath, add.LocalPath, EmbyMediaSourceId = embyMediaSourceId }); + } + + foreach (EmbyPathReplacement update in toUpdate) + { + await _dbConnection.ExecuteAsync( + @"UPDATE EmbyPathReplacement + SET EmbyPath = @EmbyPath, LocalPath = @LocalPath + WHERE Id = @Id", + new { update.EmbyPath, update.LocalPath, update.Id }); + } + + foreach (EmbyPathReplacement delete in toDelete) + { + await _dbConnection.ExecuteAsync( + @"DELETE FROM EmbyPathReplacement WHERE Id = @Id", + new { delete.Id }); + } + + return Unit.Default; + } + + public async Task> DeleteAllEmby() + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + + List allMediaSources = await context.EmbyMediaSources.ToListAsync(); + var mediaSourceIds = allMediaSources.Map(ms => ms.Id).ToList(); + context.EmbyMediaSources.RemoveRange(allMediaSources); + + List allEmbyLibraries = await context.EmbyLibraries + .Where(l => mediaSourceIds.Contains(l.MediaSourceId)) + .ToListAsync(); + var libraryIds = allEmbyLibraries.Map(l => l.Id).ToList(); + context.EmbyLibraries.RemoveRange(allEmbyLibraries); + + List movieIds = await context.EmbyMovies + .Where(m => libraryIds.Contains(m.LibraryPath.LibraryId)) + .Map(pm => pm.Id) + .ToListAsync(); + + List showIds = await context.EmbyShows + .Where(m => libraryIds.Contains(m.LibraryPath.LibraryId)) + .Map(ps => ps.Id) + .ToListAsync(); + + await context.SaveChangesAsync(); + + return movieIds.Append(showIds).ToList(); + } + + public Task EnableEmbyLibrarySync(IEnumerable libraryIds) => + _dbConnection.ExecuteAsync( + "UPDATE EmbyLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids", + new { ids = libraryIds }).Map(_ => Unit.Default); + + public async Task> DisableEmbyLibrarySync(List libraryIds) + { + await _dbConnection.ExecuteAsync( + "UPDATE EmbyLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids", + new { ids = libraryIds }); + + await _dbConnection.ExecuteAsync( + "UPDATE Library SET LastScan = null WHERE Id IN @ids", + new { ids = libraryIds }); + + List movieIds = await _dbConnection.QueryAsync( + @"SELECT m.Id FROM MediaItem m + INNER JOIN EmbyMovie pm ON pm.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids", + new { ids = libraryIds }).Map(result => result.ToList()); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyMovie pm ON pm.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids)", + new { ids = libraryIds }); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyEpisode pe ON pe.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids)", + new { ids = libraryIds }); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbySeason ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids)", + new { ids = libraryIds }); + + List showIds = await _dbConnection.QueryAsync( + @"SELECT m.Id FROM MediaItem m + INNER JOIN EmbyShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids", + new { ids = libraryIds }).Map(result => result.ToList()); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids)", + new { ids = libraryIds }); + + return movieIds.Append(showIds).ToList(); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index 4dcc9752b..c137de8ca 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Dapper; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; @@ -449,6 +450,207 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return maybeExisting; } + public Task> GetExistingEmbyMovies(EmbyLibrary library) => + _dbConnection.QueryAsync( + @"SELECT ItemId, Etag FROM EmbyMovie + INNER JOIN Movie M on EmbyMovie.Id = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { LibraryId = library.Id }) + .Map(result => result.ToList()); + + public async Task> RemoveMissingEmbyMovies(EmbyLibrary library, List movieIds) + { + List ids = await _dbConnection.QueryAsync( + @"SELECT EmbyMovie.Id FROM EmbyMovie + INNER JOIN Movie M on EmbyMovie.Id = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds", + new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList()); + + await _dbConnection.ExecuteAsync( + "DELETE FROM EmbyMovie WHERE Id IN @Ids", + new { Ids = ids }); + + return ids; + } + + public async Task AddEmby(EmbyMovie movie) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await dbContext.AddAsync(movie); + if (await dbContext.SaveChangesAsync() <= 0) + { + return false; + } + + await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return true; + } + + public async Task> UpdateEmby(EmbyMovie movie) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await dbContext.EmbyMovies + .Include(m => m.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(m => m.MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Genres) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Tags) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Studios) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Actors) + .Include(m => m.MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Filter(m => m.ItemId == movie.ItemId) + .OrderBy(m => m.ItemId) + .SingleOrDefaultAsync(); + + if (maybeExisting.IsSome) + { + EmbyMovie existing = maybeExisting.ValueUnsafe(); + + // library path is used for search indexing later + movie.LibraryPath = existing.LibraryPath; + movie.Id = existing.Id; + + existing.Etag = movie.Etag; + + // metadata + MovieMetadata metadata = existing.MovieMetadata.Head(); + MovieMetadata incomingMetadata = movie.MovieMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.Tagline = incomingMetadata.Tagline; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + + // genres + foreach (Genre genre in metadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Remove(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Add(genre); + } + + // tags + foreach (Tag tag in metadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Remove(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Add(tag); + } + + // studios + foreach (Studio studio in metadata.Studios + .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Remove(studio); + } + + foreach (Studio studio in incomingMetadata.Studios + .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Add(studio); + } + + // actors + foreach (Actor actor in metadata.Actors + .Filter( + a => incomingMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + metadata.Actors.Remove(actor); + } + + foreach (Actor actor in incomingMetadata.Actors + .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + metadata.Actors.Add(actor); + } + + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + + // version + MediaVersion version = existing.MediaVersions.Head(); + MediaVersion incomingVersion = movie.MediaVersions.Head(); + version.Name = incomingVersion.Name; + version.DateAdded = incomingVersion.DateAdded; + + // media file + MediaFile file = version.MediaFiles.Head(); + MediaFile incomingFile = incomingVersion.MediaFiles.Head(); + file.Path = incomingFile.Path; + } + + await dbContext.SaveChangesAsync(); + + return maybeExisting; + } + private static async Task>> AddMovie( TvContext dbContext, int libraryPathId, diff --git a/ErsatzTV.Infrastructure/Data/TvContext.cs b/ErsatzTV.Infrastructure/Data/TvContext.cs index 7377e9817..6392dbc1c 100644 --- a/ErsatzTV.Infrastructure/Data/TvContext.cs +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -18,14 +18,17 @@ namespace ErsatzTV.Infrastructure.Data public DbSet LocalMediaSources { get; set; } public DbSet PlexMediaSources { get; set; } public DbSet JellyfinMediaSources { get; set; } + public DbSet EmbyMediaSources { get; set; } public DbSet Libraries { get; set; } public DbSet LocalLibraries { get; set; } public DbSet LibraryPaths { get; set; } public DbSet LibraryFolders { get; set; } public DbSet PlexLibraries { get; set; } public DbSet JellyfinLibraries { get; set; } + public DbSet EmbyLibraries { get; set; } public DbSet PlexPathReplacements { get; set; } public DbSet JellyfinPathReplacements { get; set; } + public DbSet EmbyPathReplacements { get; set; } public DbSet MediaItems { get; set; } public DbSet MediaVersions { get; set; } public DbSet MediaFiles { get; set; } @@ -48,6 +51,10 @@ namespace ErsatzTV.Infrastructure.Data public DbSet JellyfinShows { get; set; } public DbSet JellyfinSeasons { get; set; } public DbSet JellyfinEpisodes { get; set; } + public DbSet EmbyMovies { get; set; } + public DbSet EmbyShows { get; set; } + public DbSet EmbySeasons { get; set; } + public DbSet EmbyEpisodes { get; set; } public DbSet Collections { get; set; } public DbSet CollectionItems { get; set; } public DbSet ProgramSchedules { get; set; } diff --git a/ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs b/ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs new file mode 100644 index 000000000..c813cab26 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Infrastructure.Emby.Models; +using LanguageExt; +using Microsoft.Extensions.Logging; +using Refit; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Emby +{ + public class EmbyApiClient : IEmbyApiClient + { + private readonly IFallbackMetadataProvider _fallbackMetadataProvider; + private readonly ILogger _logger; + + public EmbyApiClient(IFallbackMetadataProvider fallbackMetadataProvider, ILogger logger) + { + _fallbackMetadataProvider = fallbackMetadataProvider; + _logger = logger; + } + + public async Task> GetServerInformation( + string address, + string apiKey) + { + try + { + IEmbyApi service = RestService.For(address); + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + return await service.GetSystemInformation(apiKey, cts.Token) + .Map(response => new EmbyServerInformation(response.ServerName, response.OperatingSystem)); + } + catch (OperationCanceledException ex) + { + _logger.LogError(ex, "Timeout getting emby server name"); + return BaseError.New("Emby did not respond in time"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting emby server name"); + return BaseError.New(ex.Message); + } + } + + public async Task>> GetLibraries(string address, string apiKey) + { + try + { + IEmbyApi service = RestService.For(address); + List libraries = await service.GetLibraries(apiKey); + return libraries + .Map(Project) + .Somes() + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting emby libraries"); + return BaseError.New(ex.Message); + } + } + + public async Task>> GetMovieLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string libraryId) + { + try + { + IEmbyApi service = RestService.For(address); + EmbyLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, libraryId); + return items.Items + .Map(ProjectToMovie) + .Somes() + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting emby movie library items"); + return BaseError.New(ex.Message); + } + } + + public async Task>> GetShowLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string libraryId) + { + try + { + IEmbyApi service = RestService.For(address); + EmbyLibraryItemsResponse items = await service.GetShowLibraryItems(apiKey, libraryId); + return items.Items + .Map(ProjectToShow) + .Somes() + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting emby show library items"); + return BaseError.New(ex.Message); + } + } + + public async Task>> GetSeasonLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string showId) + { + try + { + IEmbyApi service = RestService.For(address); + EmbyLibraryItemsResponse items = await service.GetSeasonLibraryItems(apiKey, showId); + return items.Items + .Map(ProjectToSeason) + .Somes() + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting emby show library items"); + return BaseError.New(ex.Message); + } + } + + public async Task>> GetEpisodeLibraryItems( + string address, + string apiKey, + int mediaSourceId, + string seasonId) + { + try + { + IEmbyApi service = RestService.For(address); + EmbyLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, seasonId); + return items.Items + .Map(ProjectToEpisode) + .Somes() + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting emby episode library items"); + return BaseError.New(ex.Message); + } + } + + private static Option Project(EmbyLibraryResponse response) => + response.CollectionType?.ToLowerInvariant() switch + { + "tvshows" => new EmbyLibrary + { + ItemId = response.ItemId, + Name = response.Name, + MediaKind = LibraryMediaKind.Shows, + ShouldSyncItems = false, + Paths = new List { new() { Path = $"emby://{response.ItemId}" } } + }, + "movies" => new EmbyLibrary + { + ItemId = response.ItemId, + Name = response.Name, + MediaKind = LibraryMediaKind.Movies, + ShouldSyncItems = false, + Paths = new List { new() { Path = $"emby://{response.ItemId}" } } + }, + // TODO: ??? for music libraries + _ => None + }; + + private Option ProjectToMovie(EmbyLibraryItemResponse item) + { + try + { + if (item.MediaSources.Any(ms => ms.Protocol != "File")) + { + return None; + } + + var version = new MediaVersion + { + Name = "Main", + Duration = TimeSpan.FromTicks(item.RunTimeTicks), + DateAdded = item.DateCreated.UtcDateTime, + MediaFiles = new List + { + new() + { + Path = item.Path + } + }, + Streams = new List() + }; + + MovieMetadata metadata = ProjectToMovieMetadata(item); + + var movie = new EmbyMovie + { + ItemId = item.Id, + Etag = item.Etag, + MediaVersions = new List { version }, + MovieMetadata = new List { metadata } + }; + + return movie; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error projecting Emby movie"); + return None; + } + } + + private MovieMetadata ProjectToMovieMetadata(EmbyLibraryItemResponse item) + { + DateTime dateAdded = item.DateCreated.UtcDateTime; + // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; + + var metadata = new MovieMetadata + { + Title = item.Name, + SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), + Plot = item.Overview, + Year = item.ProductionYear, + Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), + DateAdded = dateAdded, + Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), + Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), + Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), + Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(), + Artwork = new List() + }; + + // set order on actors + for (var i = 0; i < metadata.Actors.Count; i++) + { + metadata.Actors[i].Order = i; + } + + if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) + { + metadata.ReleaseDate = releaseDate; + } + + if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) + { + var poster = new Artwork + { + ArtworkKind = ArtworkKind.Poster, + Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(poster); + } + + if (item.BackdropImageTags.Any()) + { + var fanArt = new Artwork + { + ArtworkKind = ArtworkKind.FanArt, + Path = $"emby://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(fanArt); + } + + return metadata; + } + + private Actor ProjectToModel(EmbyPersonResponse person, DateTime dateAdded) + { + var actor = new Actor { Name = person.Name, Role = person.Role }; + if (!string.IsNullOrWhiteSpace(person.Id) && !string.IsNullOrWhiteSpace(person.PrimaryImageTag)) + { + actor.Artwork = new Artwork + { + Path = $"emby://Items/{person.Id}/Images/Primary?tag={person.PrimaryImageTag}", + ArtworkKind = ArtworkKind.Thumbnail, + DateAdded = dateAdded + }; + } + + return actor; + } + + private Option ProjectToShow(EmbyLibraryItemResponse item) + { + try + { + ShowMetadata metadata = ProjectToShowMetadata(item); + + var show = new EmbyShow + { + ItemId = item.Id, + Etag = item.Etag, + ShowMetadata = new List { metadata } + }; + + return show; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error projecting Emby show"); + return None; + } + } + + private ShowMetadata ProjectToShowMetadata(EmbyLibraryItemResponse item) + { + DateTime dateAdded = item.DateCreated.UtcDateTime; + // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; + + var metadata = new ShowMetadata + { + Title = item.Name, + SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), + Plot = item.Overview, + Year = item.ProductionYear, + Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), + DateAdded = dateAdded, + Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), + Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), + Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), + Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(), + Artwork = new List() + }; + + // set order on actors + for (var i = 0; i < metadata.Actors.Count; i++) + { + metadata.Actors[i].Order = i; + } + + if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) + { + metadata.ReleaseDate = releaseDate; + } + + if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) + { + var poster = new Artwork + { + ArtworkKind = ArtworkKind.Poster, + Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(poster); + } + + if (item.BackdropImageTags.Any()) + { + var fanArt = new Artwork + { + ArtworkKind = ArtworkKind.FanArt, + Path = $"emby://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(fanArt); + } + + return metadata; + } + + private Option ProjectToSeason(EmbyLibraryItemResponse item) + { + try + { + DateTime dateAdded = item.DateCreated.UtcDateTime; + // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; + + var metadata = new SeasonMetadata + { + Title = item.Name, + SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), + Year = item.ProductionYear, + DateAdded = dateAdded, + Artwork = new List() + }; + + if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) + { + var poster = new Artwork + { + ArtworkKind = ArtworkKind.Poster, + Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(poster); + } + + if (item.BackdropImageTags.Any()) + { + var fanArt = new Artwork + { + ArtworkKind = ArtworkKind.FanArt, + Path = $"emby://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(fanArt); + } + + var season = new EmbySeason + { + ItemId = item.Id, + Etag = item.Etag, + SeasonMetadata = new List { metadata } + }; + + if (item.IndexNumber.HasValue) + { + season.SeasonNumber = item.IndexNumber.Value; + } + + return season; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error projecting Emby show"); + return None; + } + } + + private Option ProjectToEpisode(EmbyLibraryItemResponse item) + { + try + { + if (item.LocationType == "Virtual") + { + return None; + } + + var version = new MediaVersion + { + Name = "Main", + Duration = TimeSpan.FromTicks(item.RunTimeTicks), + DateAdded = item.DateCreated.UtcDateTime, + MediaFiles = new List + { + new() + { + Path = item.Path + } + }, + Streams = new List() + }; + + EpisodeMetadata metadata = ProjectToEpisodeMetadata(item); + + var episode = new EmbyEpisode + { + ItemId = item.Id, + Etag = item.Etag, + MediaVersions = new List { version }, + EpisodeMetadata = new List { metadata } + }; + + if (item.IndexNumber.HasValue) + { + episode.EpisodeNumber = item.IndexNumber.Value; + } + + return episode; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error projecting Emby movie"); + return None; + } + } + + private EpisodeMetadata ProjectToEpisodeMetadata(EmbyLibraryItemResponse item) + { + DateTime dateAdded = item.DateCreated.UtcDateTime; + // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; + + var metadata = new EpisodeMetadata + { + Title = item.Name, + SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), + Plot = item.Overview, + Year = item.ProductionYear, + DateAdded = dateAdded, + Genres = new List(), + Tags = new List(), + Studios = new List(), + Actors = new List(), + Artwork = new List() + }; + + if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) + { + metadata.ReleaseDate = releaseDate; + } + + if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) + { + var thumbnail = new Artwork + { + ArtworkKind = ArtworkKind.Thumbnail, + Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", + DateAdded = dateAdded + }; + metadata.Artwork.Add(thumbnail); + } + + return metadata; + } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/EmbySecretStore.cs b/ErsatzTV.Infrastructure/Emby/EmbySecretStore.cs new file mode 100644 index 000000000..d3ed7da65 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/EmbySecretStore.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Emby; +using LanguageExt; +using Newtonsoft.Json; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Infrastructure.Emby +{ + public class EmbySecretStore : IEmbySecretStore + { + public Task DeleteAll() => SaveSecrets(new EmbySecrets()); + + public Task ReadSecrets() => + File.ReadAllTextAsync(FileSystemLayout.EmbySecretsPath) + .Map(JsonConvert.DeserializeObject) + .Map(s => Optional(s).IfNone(new EmbySecrets())); + + public Task SaveSecrets(EmbySecrets embySecrets) => + Some(JsonConvert.SerializeObject(embySecrets)).Match( + s => File.WriteAllTextAsync(FileSystemLayout.EmbySecretsPath, s).ToUnit(), + Task.FromResult(Unit.Default)); + } +} diff --git a/ErsatzTV.Infrastructure/Emby/IEmbyApi.cs b/ErsatzTV.Infrastructure/Emby/IEmbyApi.cs new file mode 100644 index 000000000..34837a112 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/IEmbyApi.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Infrastructure.Emby.Models; +using Refit; + +namespace ErsatzTV.Infrastructure.Emby +{ + [Headers("Accept: application/json")] + public interface IEmbyApi + { + [Get("/System/Info")] + public Task GetSystemInformation( + [Header("X-Emby-Token")] + string apiKey, + CancellationToken cancellationToken); + + [Get("/Library/VirtualFolders")] + public Task> GetLibraries( + [Header("X-Emby-Token")] + string apiKey); + + [Get("/Items")] + public Task GetMovieLibraryItems( + [Header("X-Emby-Token")] + string apiKey, + [Query] + string parentId, + [Query] + string fields = + "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources", + [Query] + string includeItemTypes = "Movie"); + + [Get("/Items")] + public Task GetShowLibraryItems( + [Header("X-Emby-Token")] + string apiKey, + [Query] + string parentId, + [Query] + string fields = + "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources", + [Query] + string includeItemTypes = "Series"); + + [Get("/Items")] + public Task GetSeasonLibraryItems( + [Header("X-Emby-Token")] + string apiKey, + [Query] + string parentId, + [Query] + string fields = "Path,DateCreated,Etag,Taglines", + [Query] + string includeItemTypes = "Season"); + + [Get("/Items")] + public Task GetEpisodeLibraryItems( + [Header("X-Emby-Token")] + string apiKey, + [Query] + string parentId, + [Query] + string fields = "Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType", + [Query] + string includeItemTypes = "Episode"); + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyImageTagsResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyImageTagsResponse.cs new file mode 100644 index 000000000..af4e9ef4b --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyImageTagsResponse.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyImageTagsResponse + { + public string Primary { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs new file mode 100644 index 000000000..b423cb2b2 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyLibraryItemResponse + { + public string Name { get; set; } + public string Id { get; set; } + public string Etag { get; set; } + public string Path { get; set; } + public DateTimeOffset DateCreated { get; set; } + public long RunTimeTicks { get; set; } + public List Genres { get; set; } + public List Tags { get; set; } + public int ProductionYear { get; set; } + public string PremiereDate { get; set; } + public List MediaStreams { get; set; } + public List MediaSources { get; set; } + public string LocationType { get; set; } + public string Overview { get; set; } + public List Taglines { get; set; } + public List Studios { get; set; } + public List People { get; set; } + public EmbyImageTagsResponse ImageTags { get; set; } + public List BackdropImageTags { get; set; } + public int? IndexNumber { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemsResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemsResponse.cs new file mode 100644 index 000000000..b056dfd9d --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemsResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyLibraryItemsResponse + { + public List Items { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryResponse.cs new file mode 100644 index 000000000..581f27ce3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryResponse.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyLibraryResponse + { + public string Name { get; set; } + public string CollectionType { get; set; } + public string ItemId { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyMediaSourceResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyMediaSourceResponse.cs new file mode 100644 index 000000000..82d020e07 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyMediaSourceResponse.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyMediaSourceResponse + { + public string Id { get; set; } + public string Protocol { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyMediaStreamResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyMediaStreamResponse.cs new file mode 100644 index 000000000..56003240a --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyMediaStreamResponse.cs @@ -0,0 +1,18 @@ +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyMediaStreamResponse + { + public string Type { get; set; } + public string Codec { get; set; } + public string Language { get; set; } + public bool? IsInterlaced { get; set; } + public int? Height { get; set; } + public int? Width { get; set; } + public int Index { get; set; } + public bool IsDefault { get; set; } + public bool IsForced { get; set; } + public string Profile { get; set; } + public string AspectRatio { get; set; } + public int? Channels { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyPersonResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyPersonResponse.cs new file mode 100644 index 000000000..eafe6c080 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyPersonResponse.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyPersonResponse + { + public string Name { get; set; } + public string Id { get; set; } + public string Role { get; set; } + public string Type { get; set; } + public string PrimaryImageTag { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbyStudioResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbyStudioResponse.cs new file mode 100644 index 000000000..c64b0d13b --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbyStudioResponse.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbyStudioResponse + { + public string Name { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Emby/Models/EmbySystemInformationResponse.cs b/ErsatzTV.Infrastructure/Emby/Models/EmbySystemInformationResponse.cs new file mode 100644 index 000000000..efc9b79a3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Emby/Models/EmbySystemInformationResponse.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Infrastructure.Emby.Models +{ + public class EmbySystemInformationResponse + { + public string ServerName { get; set; } + public string OperatingSystem { get; set; } + } +} diff --git a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs index 52413c520..65036ae25 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using ErsatzTV.Infrastructure.Jellyfin.Models; using Refit; @@ -11,7 +12,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin [Get("/System/Info")] public Task GetSystemInformation( [Header("X-Emby-Token")] - string apiKey); + string apiKey, + CancellationToken cancellationToken); [Get("/Users")] public Task> GetUsers( diff --git a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs index ffaa59bb8..295462f65 100644 --- a/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs +++ b/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -39,9 +40,16 @@ namespace ErsatzTV.Infrastructure.Jellyfin try { IJellyfinApi service = RestService.For(address); - return await service.GetSystemInformation(apiKey) + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + return await service.GetSystemInformation(apiKey, cts.Token) .Map(response => new JellyfinServerInformation(response.ServerName, response.OperatingSystem)); } + catch (OperationCanceledException ex) + { + _logger.LogError(ex, "Timeout getting jellyfin server name"); + return BaseError.New("Jellyfin did not respond in time"); + } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin server name"); diff --git a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs index 767c1e838..767f1b2bf 100644 --- a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs +++ b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs @@ -6,21 +6,23 @@ namespace ErsatzTV.Infrastructure.Locking { public class EntityLocker : IEntityLocker { - private readonly ConcurrentDictionary _lockedMediaSources; - private bool _jellyfin; + private readonly ConcurrentDictionary _lockedLibraries; + private readonly ConcurrentDictionary _lockedRemoteMediaSourceTypes; private bool _plex; - public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary(); + public EntityLocker() + { + _lockedLibraries = new ConcurrentDictionary(); + _lockedRemoteMediaSourceTypes = new ConcurrentDictionary(); + } public event EventHandler OnLibraryChanged; - public event EventHandler OnPlexChanged; + public event EventHandler OnRemoteMediaSourceChanged; - public event EventHandler OnJellyfinChanged; - - public bool LockLibrary(int mediaSourceId) + public bool LockLibrary(int libraryId) { - if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0)) + if (!_lockedLibraries.ContainsKey(libraryId) && _lockedLibraries.TryAdd(libraryId, 0)) { OnLibraryChanged?.Invoke(this, EventArgs.Empty); return true; @@ -29,9 +31,9 @@ namespace ErsatzTV.Infrastructure.Locking return false; } - public bool UnlockLibrary(int mediaSourceId) + public bool UnlockLibrary(int libraryId) { - if (_lockedMediaSources.TryRemove(mediaSourceId, out byte _)) + if (_lockedLibraries.TryRemove(libraryId, out byte _)) { OnLibraryChanged?.Invoke(this, EventArgs.Empty); return true; @@ -40,8 +42,8 @@ namespace ErsatzTV.Infrastructure.Locking return false; } - public bool IsLibraryLocked(int mediaSourceId) => - _lockedMediaSources.ContainsKey(mediaSourceId); + public bool IsLibraryLocked(int libraryId) => + _lockedLibraries.ContainsKey(libraryId); public bool LockPlex() { @@ -69,24 +71,27 @@ namespace ErsatzTV.Infrastructure.Locking public bool IsPlexLocked() => _plex; - public bool LockJellyfin() + public bool LockRemoteMediaSource() { - if (!_jellyfin) + Type mediaSourceType = typeof(TMediaSource); + + if (!_lockedRemoteMediaSourceTypes.ContainsKey(mediaSourceType) && + _lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0)) { - _jellyfin = true; - OnJellyfinChanged?.Invoke(this, EventArgs.Empty); + OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType); return true; } return false; } - public bool UnlockJellyfin() + public bool UnlockRemoteMediaSource() { - if (_jellyfin) + Type mediaSourceType = typeof(TMediaSource); + + if (_lockedRemoteMediaSourceTypes.TryRemove(mediaSourceType, out byte _)) { - _jellyfin = false; - OnJellyfinChanged?.Invoke(this, EventArgs.Empty); + OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType); return true; } diff --git a/ErsatzTV.Infrastructure/Migrations/20210522151551_Add_Emby.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210522151551_Add_Emby.Designer.cs new file mode 100644 index 000000000..7ae1a4988 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210522151551_Add_Emby.Designer.cs @@ -0,0 +1,2711 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20210522151551_Add_Emby")] + partial class Add_Emby + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.6"); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ArtworkId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("ArtworkId") + .IsUnique(); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Actor"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ArtistMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistId") + .HasColumnType("INTEGER"); + + b.Property("Biography") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("Disambiguation") + .HasColumnType("TEXT"); + + b.Property("Formed") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistId"); + + b.ToTable("ArtistMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ArtworkKind") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("PreferredLanguageCode") + .HasColumnType("TEXT"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channel"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("UseCustomPlaybackOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("CustomIndex") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "MediaItemId"); + + b.HasIndex("MediaItemId"); + + b.ToTable("CollectionItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("EmbyMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EmbyMediaSourceId"); + + b.ToTable("EmbyConnection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyPathReplacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EmbyMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("EmbyPath") + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmbyMediaSourceId"); + + b.ToTable("EmbyPathReplacement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.ToTable("EpisodeMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioBitrate") + .HasColumnType("INTEGER"); + + b.Property("AudioBufferSize") + .HasColumnType("INTEGER"); + + b.Property("AudioChannels") + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("FrameRate") + .HasColumnType("TEXT"); + + b.Property("HardwareAcceleration") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeLoudness") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideo") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("Transcode") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("JellyfinMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JellyfinMediaSourceId"); + + b.ToTable("JellyfinConnection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinPathReplacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JellyfinMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("JellyfinPath") + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JellyfinMediaSourceId"); + + b.ToTable("JellyfinPathReplacement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScan") + .HasColumnType("TEXT"); + + b.Property("MediaKind") + .HasColumnType("INTEGER"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("LibraryPathId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryPathId"); + + b.ToTable("LibraryFolder"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScan") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaVersionId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaVersionId"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaFile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryPathId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryPathId"); + + b.ToTable("MediaItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Default") + .HasColumnType("INTEGER"); + + b.Property("Forced") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("MediaStreamKind") + .HasColumnType("INTEGER"); + + b.Property("MediaVersionId") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaVersionId"); + + b.ToTable("MediaStream"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b.Property("VideoScanKind") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("MusicVideoId"); + + b.ToTable("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.ToTable("Mood"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.ToTable("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoId") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MusicVideoId"); + + b.ToTable("MusicVideoMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomGroup") + .HasColumnType("INTEGER"); + + b.Property("CustomTitle") + .HasColumnType("TEXT"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.ToTable("PlayoutItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleAnchor"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexConnection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexPathReplacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("PlexPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexPathReplacement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("KeepMultiPartEpisodesTogether") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionPlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("CustomTitle") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("ProgramScheduleItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("SeasonMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Outline") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.ToTable("ShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Studio"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Style", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.ToTable("Style"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("EmbyLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("JellyfinLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.ToTable("LocalLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("PlexLibrary"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaFile", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaFile"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("PlexId") + .HasColumnType("INTEGER"); + + b.ToTable("PlexMediaFile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Artist"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("Episode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideo", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("ArtistId") + .HasColumnType("INTEGER"); + + b.HasIndex("ArtistId"); + + b.ToTable("MusicVideo"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("ShowId") + .HasColumnType("INTEGER"); + + b.HasIndex("ShowId"); + + b.ToTable("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("OperatingSystem") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("EmbyMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("OperatingSystem") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.ToTable("LocalMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("TEXT"); + + b.Property("PlatformVersion") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("OfflineTail") + .HasColumnType("INTEGER"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.ToTable("ProgramScheduleDurationItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyEpisode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyEpisode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinEpisode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinEpisode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexEpisode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexEpisode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyMovie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinMovie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexMovie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbySeason"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinSeason", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinSeason"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexSeason", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexSeason"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyShow", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinShow", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexShow", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Actors") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.Artwork", "Artwork") + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Actor", "ArtworkId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Actors") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Actors") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Actors") + .HasForeignKey("MusicVideoMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Actors") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Actors") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ArtistMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist") + .WithMany("ArtistMetadata") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Artwork") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Channel", null) + .WithMany("Artwork") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Artwork") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Artwork") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Artwork") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Artwork") + .HasForeignKey("SeasonMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Artwork") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") + .WithMany() + .HasForeignKey("FFmpegProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany("CollectionItems") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany("CollectionItems") + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("MediaItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.EmbyMediaSource", "EmbyMediaSource") + .WithMany("Connections") + .HasForeignKey("EmbyMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmbyMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyPathReplacement", b => + { + b.HasOne("ErsatzTV.Core.Domain.EmbyMediaSource", "EmbyMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("EmbyMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmbyMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", "Episode") + .WithMany("EpisodeMetadata") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Episode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Genre", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Genres") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Genres") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Genres") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Genres") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Genres") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Genres") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.JellyfinMediaSource", "JellyfinMediaSource") + .WithMany("Connections") + .HasForeignKey("JellyfinMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JellyfinMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinPathReplacement", b => + { + b.HasOne("ErsatzTV.Core.Domain.JellyfinMediaSource", "JellyfinMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("JellyfinMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JellyfinMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "MediaSource") + .WithMany("Libraries") + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryFolder", b => + { + b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath") + .WithMany("LibraryFolders") + .HasForeignKey("LibraryPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", "Library") + .WithMany("Paths") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") + .WithMany("MediaFiles") + .HasForeignKey("MediaVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath") + .WithMany("MediaItems") + .HasForeignKey("LibraryPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaStream", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") + .WithMany("Streams") + .HasForeignKey("MediaVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithMany("MediaVersions") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithMany("MediaVersions") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideo", null) + .WithMany("MediaVersions") + .HasForeignKey("MusicVideoId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Moods") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", "Movie") + .WithMany("MovieMetadata") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo") + .WithMany("MusicVideoMetadata") + .HasForeignKey("MusicVideoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MusicVideo"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("DurationFinish") + .HasColumnType("TEXT"); + + b1.Property("MultipleRemaining") + .HasColumnType("INTEGER"); + + b1.Property("NextScheduleItemId") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.HasIndex("NextScheduleItemId"); + + b1.ToTable("Playout"); + + b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem") + .WithMany() + .HasForeignKey("NextScheduleItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.Navigation("NextScheduleItem"); + }); + + b.Navigation("Anchor"); + + b.Navigation("Channel"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("Items") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("ProgramScheduleAnchors") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany() + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorId"); + + b1.ToTable("PlayoutProgramScheduleAnchor"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorId"); + }); + + b.Navigation("Collection"); + + b.Navigation("EnumeratorState"); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexPathReplacement", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("PlexMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlexMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("MediaItem"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", "Season") + .WithMany("SeasonMetadata") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", "Show") + .WithMany("ShowMetadata") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Studio", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Studios") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Studios") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Studios") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Studios") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Studios") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Studios") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Style", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Styles") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Tags") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Tags") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Tags") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Tags") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Tags") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Tags") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaFile", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaFile", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaFile", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Artist", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Episode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Movie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideo", b => + { + b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist") + .WithMany("MusicVideos") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.MusicVideo", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Season", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Show", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyEpisode", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinEpisode", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexEpisode", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMovie", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMovie", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbySeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinSeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexSeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ArtistMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Moods"); + + b.Navigation("Studios"); + + b.Navigation("Styles"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Artwork"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Navigation("CollectionItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Studios"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Navigation("Paths"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Navigation("LibraryFolders"); + + b.Navigation("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Navigation("CollectionItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Navigation("Libraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Navigation("MediaFiles"); + + b.Navigation("Streams"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Studios"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Studios"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Studios"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Studios"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => + { + b.Navigation("ArtistMetadata"); + + b.Navigation("MusicVideos"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.Navigation("EpisodeMetadata"); + + b.Navigation("MediaVersions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideo", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("MusicVideoMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + { + b.Navigation("Episodes"); + + b.Navigation("SeasonMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.Navigation("Seasons"); + + b.Navigation("ShowMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210522151551_Add_Emby.cs b/ErsatzTV.Infrastructure/Migrations/20210522151551_Add_Emby.cs new file mode 100644 index 000000000..21b44fab7 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210522151551_Add_Emby.cs @@ -0,0 +1,208 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Add_Emby : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "EmbyEpisode", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ItemId = table.Column("TEXT", nullable: true), + Etag = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbyEpisode", x => x.Id); + table.ForeignKey( + "FK_EmbyEpisode_Episode_Id", + x => x.Id, + "Episode", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "EmbyLibrary", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ItemId = table.Column("TEXT", nullable: true), + ShouldSyncItems = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbyLibrary", x => x.Id); + table.ForeignKey( + "FK_EmbyLibrary_Library_Id", + x => x.Id, + "Library", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "EmbyMediaSource", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ServerName = table.Column("TEXT", nullable: true), + OperatingSystem = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbyMediaSource", x => x.Id); + table.ForeignKey( + "FK_EmbyMediaSource_MediaSource_Id", + x => x.Id, + "MediaSource", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "EmbyMovie", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ItemId = table.Column("TEXT", nullable: true), + Etag = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbyMovie", x => x.Id); + table.ForeignKey( + "FK_EmbyMovie_Movie_Id", + x => x.Id, + "Movie", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "EmbySeason", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ItemId = table.Column("TEXT", nullable: true), + Etag = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbySeason", x => x.Id); + table.ForeignKey( + "FK_EmbySeason_Season_Id", + x => x.Id, + "Season", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "EmbyShow", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ItemId = table.Column("TEXT", nullable: true), + Etag = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbyShow", x => x.Id); + table.ForeignKey( + "FK_EmbyShow_Show_Id", + x => x.Id, + "Show", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "EmbyConnection", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Address = table.Column("TEXT", nullable: true), + EmbyMediaSourceId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbyConnection", x => x.Id); + table.ForeignKey( + "FK_EmbyConnection_EmbyMediaSource_EmbyMediaSourceId", + x => x.EmbyMediaSourceId, + "EmbyMediaSource", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "EmbyPathReplacement", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + EmbyPath = table.Column("TEXT", nullable: true), + LocalPath = table.Column("TEXT", nullable: true), + EmbyMediaSourceId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmbyPathReplacement", x => x.Id); + table.ForeignKey( + "FK_EmbyPathReplacement_EmbyMediaSource_EmbyMediaSourceId", + x => x.EmbyMediaSourceId, + "EmbyMediaSource", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + "IX_EmbyConnection_EmbyMediaSourceId", + "EmbyConnection", + "EmbyMediaSourceId"); + + migrationBuilder.CreateIndex( + "IX_EmbyPathReplacement_EmbyMediaSourceId", + "EmbyPathReplacement", + "EmbyMediaSourceId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "EmbyConnection"); + + migrationBuilder.DropTable( + "EmbyEpisode"); + + migrationBuilder.DropTable( + "EmbyLibrary"); + + migrationBuilder.DropTable( + "EmbyMovie"); + + migrationBuilder.DropTable( + "EmbyPathReplacement"); + + migrationBuilder.DropTable( + "EmbySeason"); + + migrationBuilder.DropTable( + "EmbyShow"); + + migrationBuilder.DropTable( + "EmbyMediaSource"); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 4de0b4bda..92fa1cb75 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "5.0.4"); + .HasAnnotation("ProductVersion", "5.0.6"); modelBuilder.Entity( "ErsatzTV.Core.Domain.Actor", @@ -282,6 +282,51 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ConfigElement"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyConnection", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("EmbyMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EmbyMediaSourceId"); + + b.ToTable("EmbyConnection"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyPathReplacement", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EmbyMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("EmbyPath") + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmbyMediaSourceId"); + + b.ToTable("EmbyPathReplacement"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.EpisodeMetadata", b => @@ -1281,6 +1326,21 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Tag"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyLibrary", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("EmbyLibrary"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinLibrary", b => @@ -1410,6 +1470,21 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Show"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyMediaSource", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("OperatingSystem") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("EmbyMediaSource"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinMediaSource", b => @@ -1503,6 +1578,21 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ProgramScheduleOneItem"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyEpisode", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyEpisode"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinEpisode", b => @@ -1530,6 +1620,21 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexEpisode"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyMovie", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyMovie"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinMovie", b => @@ -1557,6 +1662,21 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexMovie"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbySeason", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbySeason"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinSeason", b => @@ -1584,6 +1704,21 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexSeason"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyShow", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyShow"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinShow", b => @@ -1737,6 +1872,32 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("MediaItem"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyConnection", + b => + { + b.HasOne("ErsatzTV.Core.Domain.EmbyMediaSource", "EmbyMediaSource") + .WithMany("Connections") + .HasForeignKey("EmbyMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmbyMediaSource"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyPathReplacement", + b => + { + b.HasOne("ErsatzTV.Core.Domain.EmbyMediaSource", "EmbyMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("EmbyMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmbyMediaSource"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.EpisodeMetadata", b => @@ -2250,6 +2411,17 @@ namespace ErsatzTV.Infrastructure.Migrations .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyLibrary", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinLibrary", b => @@ -2384,6 +2556,17 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyMediaSource", + b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinMediaSource", b => @@ -2461,6 +2644,17 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyEpisode", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinEpisode", b => @@ -2483,6 +2677,17 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyMovie", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinMovie", b => @@ -2505,6 +2710,17 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbySeason", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbySeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinSeason", b => @@ -2527,6 +2743,17 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyShow", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinShow", b => @@ -2750,6 +2977,15 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("ShowMetadata"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EmbyMediaSource", + b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.JellyfinMediaSource", b => diff --git a/ErsatzTV.sln.DotSettings b/ErsatzTV.sln.DotSettings index 16ef24cef..9add9b05f 100644 --- a/ErsatzTV.sln.DotSettings +++ b/ErsatzTV.sln.DotSettings @@ -12,6 +12,7 @@ True True True + True True True True diff --git a/ErsatzTV/Pages/EmbyLibrariesEditor.razor b/ErsatzTV/Pages/EmbyLibrariesEditor.razor new file mode 100644 index 000000000..0ff9aba82 --- /dev/null +++ b/ErsatzTV/Pages/EmbyLibrariesEditor.razor @@ -0,0 +1,49 @@ +@page "/media/sources/emby/{Id:int}/libraries" +@using Unit = LanguageExt.Unit +@using ErsatzTV.Application.Emby.Commands +@using ErsatzTV.Application.Emby.Queries +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.Emby +@inject IMediator _mediator +@inject ChannelWriter _channel + + + +@code { + + [Parameter] + public int Id { get; set; } + + private IRequest> GetUpdateLibraryRequest(List libraries) => + new UpdateEmbyLibraryPreferences( + libraries.Map(l => new EmbyLibraryPreference(l.Id, l.ShouldSyncItems)).ToList()); + + private Task> GetLibrariesBySourceId(int mediaSourceId) => + _mediator.Send(new GetEmbyLibrariesBySourceId(Id)) + .Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList()); + + private Task> GetMediaSourceById(int mediaSourceId) => + _mediator.Send(new GetEmbyMediaSourceById(Id)) + .MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address)); + + private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(EmbyLibraryViewModel library) => new() + { + Id = library.Id, + Name = library.Name, + MediaKind = library.MediaKind, + ShouldSyncItems = library.ShouldSyncItems + }; + + private async Task SynchronizeLibraryByIdIfNeeded(int libraryId) + { + await _channel.WriteAsync(new SynchronizeEmbyLibraryByIdIfNeeded(libraryId)); + return Unit.Default; + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/EmbyMediaSourceEditor.razor b/ErsatzTV/Pages/EmbyMediaSourceEditor.razor new file mode 100644 index 000000000..7d4bf84c7 --- /dev/null +++ b/ErsatzTV/Pages/EmbyMediaSourceEditor.razor @@ -0,0 +1,32 @@ +@page "/media/emby/edit" +@using Unit = LanguageExt.Unit +@using ErsatzTV.Core.Emby +@using ErsatzTV.Application.Emby.Queries +@using ErsatzTV.Application.Emby.Commands +@inject IMediator _mediator +@inject NavigationManager _navigationManager +@inject ISnackbar _snackbar +@inject ILogger _logger + + + +@code { + + private async Task LoadSecrets(RemoteMediaSourceEditViewModel viewModel) + { + EmbySecrets secrets = await _mediator.Send(new GetEmbySecrets()); + viewModel.Address = secrets.Address; + viewModel.ApiKey = secrets.ApiKey; + return Unit.Default; + } + + private async Task> SaveSecrets(RemoteMediaSourceEditViewModel viewModel) + { + var secrets = new EmbySecrets { Address = viewModel.Address, ApiKey = viewModel.ApiKey }; + return await _mediator.Send(new SaveEmbySecrets(secrets)); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/EmbyMediaSources.razor b/ErsatzTV/Pages/EmbyMediaSources.razor new file mode 100644 index 000000000..2f9a67db0 --- /dev/null +++ b/ErsatzTV/Pages/EmbyMediaSources.razor @@ -0,0 +1,14 @@ +@page "/media/emby" +@using ErsatzTV.Core.Interfaces.Emby +@using ErsatzTV.Application.Emby.Queries +@using ErsatzTV.Application.Emby.Commands +@inject IEmbySecretStore _embySecretStore + + \ No newline at end of file diff --git a/ErsatzTV/Pages/EmbyPathReplacementsEditor.razor b/ErsatzTV/Pages/EmbyPathReplacementsEditor.razor new file mode 100644 index 000000000..83143845c --- /dev/null +++ b/ErsatzTV/Pages/EmbyPathReplacementsEditor.razor @@ -0,0 +1,44 @@ +@page "/media/sources/emby/{Id:int}/paths" +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.Emby.Queries +@using ErsatzTV.Application.Emby +@using ErsatzTV.Application.Emby.Commands +@using Unit = LanguageExt.Unit +@inject NavigationManager _navigationManager +@inject ILogger _logger +@inject ISnackbar _snackbar +@inject IMediator _mediator + + + +@code { + + [Parameter] + public int Id { get; set; } + + private Task> GetMediaSourceById(int id) => + _mediator.Send(new GetEmbyMediaSourceById(Id)) + .MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address)); + + private Task> GetPathReplacementsBySourceId(int mediaSourceId) => + _mediator.Send(new GetEmbyPathReplacementsBySourceId(Id)) + .Map(list => list.Map(ProjectToEditViewModel).ToList()); + + private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(EmbyPathReplacementViewModel item) => + new() { Id = item.Id, RemotePath = item.EmbyPath, LocalPath = item.LocalPath }; + + private IRequest> GetUpdatePathReplacementsRequest(List pathReplacements) + { + var items = pathReplacements + .Map(item => new EmbyPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) + .ToList(); + + return new UpdateEmbyPathReplacements(Id, items); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/JellyfinLibrariesEditor.razor b/ErsatzTV/Pages/JellyfinLibrariesEditor.razor index 56aac6ca0..4b39ffc62 100644 --- a/ErsatzTV/Pages/JellyfinLibrariesEditor.razor +++ b/ErsatzTV/Pages/JellyfinLibrariesEditor.razor @@ -1,78 +1,38 @@ @page "/media/sources/jellyfin/{Id:int}/libraries" -@using ErsatzTV.Application.Jellyfin +@using Unit = LanguageExt.Unit @using ErsatzTV.Application.Jellyfin.Commands @using ErsatzTV.Application.Jellyfin.Queries +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.Jellyfin @inject IMediator _mediator -@inject NavigationManager _navigationManager -@inject ILogger _logger -@inject ISnackbar _snackbar @inject ChannelWriter _channel -@inject IEntityLocker _locker - - - - @_source.Name Libraries - - - - - - - - - - Name - - - - - Media Kind - - - Synchronize - - - @context.Name - @context.MediaKind - - - - - - - Save Changes - - + @code { [Parameter] public int Id { get; set; } - private JellyfinMediaSourceViewModel _source; - private List _libraries; + private IRequest> GetUpdateLibraryRequest(List libraries) => + new UpdateJellyfinLibraryPreferences( + libraries.Map(l => new JellyfinLibraryPreference(l.Id, l.ShouldSyncItems)).ToList()); - protected override Task OnParametersSetAsync() => LoadData(); + private Task> GetLibrariesBySourceId(int mediaSourceId) => + _mediator.Send(new GetJellyfinLibrariesBySourceId(Id)) + .Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList()); - private async Task LoadData() - { - Option maybeSource = await _mediator.Send(new GetJellyfinMediaSourceById(Id)); - await maybeSource.Match( - async source => - { - _source = source; - _libraries = await _mediator.Send(new GetJellyfinLibrariesBySourceId(Id)) - .Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList()); - }, - () => - { - _navigationManager.NavigateTo("404"); - return Task.CompletedTask; - }); - } + private Task> GetMediaSourceById(int mediaSourceId) => + _mediator.Send(new GetJellyfinMediaSourceById(Id)) + .MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address)); - private JellyfinMediaSourceLibraryEditViewModel ProjectToEditViewModel(JellyfinLibraryViewModel library) => new() + private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(JellyfinLibraryViewModel library) => new() { Id = library.Id, Name = library.Name, @@ -80,32 +40,10 @@ ShouldSyncItems = library.ShouldSyncItems }; - private async Task SaveChanges() + private async Task SynchronizeLibraryByIdIfNeeded(int libraryId) { - var request = new UpdateJellyfinLibraryPreferences( - _libraries.Map(l => new JellyfinLibraryPreference(l.Id, l.ShouldSyncItems)).ToList()); - - Seq errorMessages = await _mediator.Send(request).Map(e => e.LeftToSeq()); - - await errorMessages.HeadOrNone().Match( - error => - { - _snackbar.Add($"Unexpected error saving jellyfin libraries: {error.Value}", Severity.Error); - _logger.LogError("Unexpected error saving jellyfin libraries: {Error}", error.Value); - return Task.CompletedTask; - }, - async () => - { - foreach (int id in _libraries.Filter(l => l.ShouldSyncItems).Map(l => l.Id)) - { - if (_locker.LockLibrary(id)) - { - await _channel.WriteAsync(new SynchronizeJellyfinLibraryByIdIfNeeded(id)); - } - } - - _navigationManager.NavigateTo("/media/jellyfin"); - }); + await _channel.WriteAsync(new SynchronizeJellyfinLibraryByIdIfNeeded(libraryId)); + return Unit.Default; } } \ No newline at end of file diff --git a/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor b/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor index 21dc4f46a..7eb79bb6a 100644 --- a/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor +++ b/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor @@ -1,66 +1,32 @@ @page "/media/jellyfin/edit" +@using Unit = LanguageExt.Unit @using ErsatzTV.Core.Jellyfin @using ErsatzTV.Application.Jellyfin.Queries -@using Unit = LanguageExt.Unit @using ErsatzTV.Application.Jellyfin.Commands @inject IMediator _mediator @inject NavigationManager _navigationManager @inject ISnackbar _snackbar @inject ILogger _logger - - Jellyfin Media Source -
- - - - - - - - - - Save Changes - - - - -
-
+ @code { - private readonly JellyfinMediaSourceEditViewModel _model = new(); - private EditContext _editContext; - private ValidationMessageStore _messageStore; - protected override async Task OnParametersSetAsync() + private async Task LoadSecrets(RemoteMediaSourceEditViewModel viewModel) { JellyfinSecrets secrets = await _mediator.Send(new GetJellyfinSecrets()); - _model.Address = secrets.Address; - _model.ApiKey = secrets.ApiKey; - } - - protected override void OnInitialized() - { - _editContext = new EditContext(_model); - _messageStore = new ValidationMessageStore(_editContext); + viewModel.Address = secrets.Address; + viewModel.ApiKey = secrets.ApiKey; + return Unit.Default; } - private async Task HandleSubmitAsync() + private async Task> SaveSecrets(RemoteMediaSourceEditViewModel viewModel) { - _messageStore.Clear(); - if (_editContext.Validate()) - { - var secrets = new JellyfinSecrets { Address = _model.Address, ApiKey = _model.ApiKey }; - Either result = await _mediator.Send(new SaveJellyfinSecrets(secrets)); - result.Match( - _ => _navigationManager.NavigateTo("/media/jellyfin"), - error => - { - _snackbar.Add(error.Value, Severity.Error); - _logger.LogError("Error saving jellyfin secrets: {Error}", error.Value); - }); - } + var secrets = new JellyfinSecrets { Address = viewModel.Address, ApiKey = viewModel.ApiKey }; + return await _mediator.Send(new SaveJellyfinSecrets(secrets)); } } \ No newline at end of file diff --git a/ErsatzTV/Pages/JellyfinMediaSources.razor b/ErsatzTV/Pages/JellyfinMediaSources.razor index 5b744d810..c80a911c0 100644 --- a/ErsatzTV/Pages/JellyfinMediaSources.razor +++ b/ErsatzTV/Pages/JellyfinMediaSources.razor @@ -1,105 +1,14 @@ @page "/media/jellyfin" @using ErsatzTV.Core.Interfaces.Jellyfin -@using ErsatzTV.Application.Jellyfin -@using ErsatzTV.Application.Jellyfin.Commands @using ErsatzTV.Application.Jellyfin.Queries +@using ErsatzTV.Application.Jellyfin.Commands @inject IJellyfinSecretStore _jellyfinSecretStore -@inject IMediator _mediator -@inject IDialogService _dialog -@inject IEntityLocker _locker - - - - - Jellyfin Media Source - - - - - - - - Name - Address - - - - @context.Name - @context.Address - -
- - - - - - - - -
-
-
-
- @if (_mediaSources.Any()) - { - - Disconnect Jellyfin - - } - else - { - - Connect Jellyfin - - } - - @if (_mediaSources.Any() && !_isAuthorized) - { - - Fix Jellyfin Connection - - } - -
- -@code { - private List _mediaSources = new(); - - private bool _isAuthorized; - - protected override async Task OnParametersSetAsync() => await LoadMediaSources(); - - private async Task LoadMediaSources() - { - _isAuthorized = await _jellyfinSecretStore.ReadSecrets() - .Map(secrets => !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey)); - _mediaSources = await _mediator.Send(new GetAllJellyfinMediaSources()); - } - - private async Task DisconnectJellyfin() - { - var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }; - IDialogReference dialog = _dialog.Show("DisconnectJellyfin", options); - DialogResult result = await dialog.Result; - if (!result.Cancelled) - { - if (_locker.LockJellyfin()) - { - await _mediator.Send(new DisconnectJellyfin()); - await LoadMediaSources(); - } - } - } -} \ No newline at end of file + \ No newline at end of file diff --git a/ErsatzTV/Pages/JellyfinPathReplacementsEditor.razor b/ErsatzTV/Pages/JellyfinPathReplacementsEditor.razor index 22ff98db3..fbd451f9d 100644 --- a/ErsatzTV/Pages/JellyfinPathReplacementsEditor.razor +++ b/ErsatzTV/Pages/JellyfinPathReplacementsEditor.razor @@ -1,135 +1,44 @@ @page "/media/sources/jellyfin/{Id:int}/paths" +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.Jellyfin.Queries @using ErsatzTV.Application.Jellyfin @using ErsatzTV.Application.Jellyfin.Commands -@using ErsatzTV.Application.Jellyfin.Queries +@using Unit = LanguageExt.Unit @inject NavigationManager _navigationManager @inject ILogger _logger @inject ISnackbar _snackbar @inject IMediator _mediator - - - - @_source.Name Path Replacements - - - - - - - - Jellyfin Path - Local Path - - - - - - @context.JellyfinPath - - - - - @context.LocalPath - - - - - - - - - - - - Add Path Replacement - - - Save Changes - - - @if (_selectedItem is not null) - { -
- - - - - - - - - -
- } -
+ @code { [Parameter] public int Id { get; set; } - private JellyfinMediaSourceViewModel _source; - private List _pathReplacements; - - private JellyfinPathReplacementEditViewModel _selectedItem; - - protected override Task OnParametersSetAsync() => LoadData(); - - private async Task LoadData() - { - Option maybeSource = await _mediator.Send(new GetJellyfinMediaSourceById(Id)); - await maybeSource.Match( - async source => - { - _source = source; - _pathReplacements = await _mediator.Send(new GetJellyfinPathReplacementsBySourceId(Id)) - .Map(list => list.Map(ProjectToEditViewModel).ToList()); - }, - () => - { - _navigationManager.NavigateTo("404"); - return Task.CompletedTask; - }); - } + private Task> GetMediaSourceById(int id) => + _mediator.Send(new GetJellyfinMediaSourceById(Id)) + .MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address)); - private JellyfinPathReplacementEditViewModel ProjectToEditViewModel(JellyfinPathReplacementViewModel item) => - new() { Id = item.Id, JellyfinPath = item.JellyfinPath, LocalPath = item.LocalPath }; - - private void AddPathReplacement() - { - var item = new JellyfinPathReplacementEditViewModel(); - _pathReplacements.Add(item); - _selectedItem = item; - } + private Task> GetPathReplacementsBySourceId(int mediaSourceId) => + _mediator.Send(new GetJellyfinPathReplacementsBySourceId(Id)) + .Map(list => list.Map(ProjectToEditViewModel).ToList()); - private void RemovePathReplacement(JellyfinPathReplacementEditViewModel item) - { - _selectedItem = null; - _pathReplacements.Remove(item); - } + private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(JellyfinPathReplacementViewModel item) => + new() { Id = item.Id, RemotePath = item.JellyfinPath, LocalPath = item.LocalPath }; - private async Task SaveChanges() + private IRequest> GetUpdatePathReplacementsRequest(List pathReplacements) { - var items = _pathReplacements - .Map(item => new JellyfinPathReplacementItem(item.Id, item.JellyfinPath, item.LocalPath)) + var items = pathReplacements + .Map(item => new JellyfinPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) .ToList(); - Seq errorMessages = await _mediator.Send(new UpdateJellyfinPathReplacements(Id, items)).Map(e => e.LeftToSeq()); - - errorMessages.HeadOrNone().Match( - error => - { - _snackbar.Add($"Unexpected error saving path replacements: {error.Value}", Severity.Error); - _logger.LogError("Unexpected error saving path replacements: {Error}", error.Value); - }, - () => _navigationManager.NavigateTo("/media/jellyfin")); + return new UpdateJellyfinPathReplacements(Id, items); } } \ No newline at end of file diff --git a/ErsatzTV/Pages/Libraries.razor b/ErsatzTV/Pages/Libraries.razor index ee8284a76..4514df09b 100644 --- a/ErsatzTV/Pages/Libraries.razor +++ b/ErsatzTV/Pages/Libraries.razor @@ -8,12 +8,15 @@ @using System.Threading @using ErsatzTV.Application.Jellyfin @using ErsatzTV.Application.Jellyfin.Commands +@using ErsatzTV.Application.Emby +@using ErsatzTV.Application.Emby.Commands @implements IDisposable @inject IMediator Mediator @inject IEntityLocker Locker @inject ChannelWriter WorkerChannel @inject ChannelWriter PlexWorkerChannel @inject ChannelWriter JellyfinWorkerChannel +@inject ChannelWriter EmbyWorkerChannel @inject ICourier Courier @@ -116,6 +119,9 @@ case JellyfinLibraryViewModel: await JellyfinWorkerChannel.WriteAsync(new ForceSynchronizeJellyfinLibraryById(library.Id)); break; + case EmbyLibraryViewModel: + await EmbyWorkerChannel.WriteAsync(new ForceSynchronizeEmbyLibraryById(library.Id)); + break; } StateHasChanged(); diff --git a/ErsatzTV/Pages/PlexLibrariesEditor.razor b/ErsatzTV/Pages/PlexLibrariesEditor.razor index 2f358a9d7..393b6df50 100644 --- a/ErsatzTV/Pages/PlexLibrariesEditor.razor +++ b/ErsatzTV/Pages/PlexLibrariesEditor.razor @@ -1,78 +1,38 @@ @page "/media/sources/plex/{Id:int}/libraries" -@using ErsatzTV.Application.Plex +@using Unit = LanguageExt.Unit @using ErsatzTV.Application.Plex.Commands @using ErsatzTV.Application.Plex.Queries -@inject IMediator Mediator -@inject NavigationManager NavigationManager -@inject ILogger Logger -@inject ISnackbar Snackbar -@inject ChannelWriter Channel -@inject IEntityLocker Locker +@using ErsatzTV.Application.MediaSources +@using ErsatzTV.Application.Plex +@inject IMediator _mediator +@inject ChannelWriter _channel - - - - @_source.Name Libraries - - - - - - - - - - Name - - - - - Media Kind - - - Synchronize - - - @context.Name - @context.MediaKind - - - - - - - Save Changes - - + @code { [Parameter] public int Id { get; set; } - private PlexMediaSourceViewModel _source; - private List _libraries; + private IRequest> GetUpdateLibraryRequest(List libraries) => + new UpdatePlexLibraryPreferences( + libraries.Map(l => new PlexLibraryPreference(l.Id, l.ShouldSyncItems)).ToList()); - protected override Task OnParametersSetAsync() => LoadData(); + private Task> GetLibrariesBySourceId(int mediaSourceId) => + _mediator.Send(new GetPlexLibrariesBySourceId(Id)) + .Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList()); - private async Task LoadData() - { - Option maybeSource = await Mediator.Send(new GetPlexMediaSourceById(Id)); - await maybeSource.Match( - async source => - { - _source = source; - _libraries = await Mediator.Send(new GetPlexLibrariesBySourceId(Id)) - .Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList()); - }, - () => - { - NavigationManager.NavigateTo("404"); - return Task.CompletedTask; - }); - } + private Task> GetMediaSourceById(int mediaSourceId) => + _mediator.Send(new GetPlexMediaSourceById(Id)) + .MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address)); - private PlexMediaSourceLibraryEditViewModel ProjectToEditViewModel(PlexLibraryViewModel library) => new() + private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(PlexLibraryViewModel library) => new() { Id = library.Id, Name = library.Name, @@ -80,32 +40,10 @@ ShouldSyncItems = library.ShouldSyncItems }; - private async Task SaveChanges() + private async Task SynchronizeLibraryByIdIfNeeded(int libraryId) { - var request = new UpdatePlexLibraryPreferences( - _libraries.Map(l => new PlexLibraryPreference(l.Id, l.ShouldSyncItems)).ToList()); - - Seq errorMessages = await Mediator.Send(request).Map(e => e.LeftToSeq()); - - await errorMessages.HeadOrNone().Match( - error => - { - Snackbar.Add($"Unexpected error saving plex libraries: {error.Value}", Severity.Error); - Logger.LogError("Unexpected error saving plex libraries: {Error}", error.Value); - return Task.CompletedTask; - }, - async () => - { - foreach (int id in _libraries.Filter(l => l.ShouldSyncItems).Map(l => l.Id)) - { - if (Locker.LockLibrary(id)) - { - await Channel.WriteAsync(new SynchronizePlexLibraryByIdIfNeeded(id)); - } - } - - NavigationManager.NavigateTo("/media/plex"); - }); + await _channel.WriteAsync(new SynchronizePlexLibraryByIdIfNeeded(libraryId)); + return Unit.Default; } } \ No newline at end of file diff --git a/ErsatzTV/Pages/PlexPathReplacementsEditor.razor b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor index 2345408de..8a9dfc0a2 100644 --- a/ErsatzTV/Pages/PlexPathReplacementsEditor.razor +++ b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor @@ -1,135 +1,44 @@ @page "/media/sources/plex/{Id:int}/paths" +@using ErsatzTV.Application.MediaSources @using ErsatzTV.Application.Plex @using ErsatzTV.Application.Plex.Commands @using ErsatzTV.Application.Plex.Queries -@inject NavigationManager NavigationManager -@inject ILogger Logger -@inject ISnackbar Snackbar -@inject IMediator Mediator - - - - - @_source.Name Path Replacements - - - - - - - - Plex Path - Local Path - - - - - - @context.PlexPath - - - - - @context.LocalPath - - - - - - - - - - - - Add Path Replacement - - - Save Changes - - - @if (_selectedItem is not null) - { -
- - - - - - - - - -
- } -
+@using Unit = LanguageExt.Unit +@inject NavigationManager _navigationManager +@inject ILogger _logger +@inject ISnackbar _snackbar +@inject IMediator _mediator + + @code { [Parameter] public int Id { get; set; } - private PlexMediaSourceViewModel _source; - private List _pathReplacements; + private Task> GetMediaSourceById(int id) => + _mediator.Send(new GetPlexMediaSourceById(Id)) + .MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address)); - private PlexPathReplacementEditViewModel _selectedItem; - - protected override Task OnParametersSetAsync() => LoadData(); - - private async Task LoadData() - { - Option maybeSource = await Mediator.Send(new GetPlexMediaSourceById(Id)); - await maybeSource.Match( - async source => - { - _source = source; - _pathReplacements = await Mediator.Send(new GetPlexPathReplacementsBySourceId(Id)) - .Map(list => list.Map(ProjectToEditViewModel).ToList()); - }, - () => - { - NavigationManager.NavigateTo("404"); - return Task.CompletedTask; - }); - } + private Task> GetPathReplacementsBySourceId(int mediaSourceId) => + _mediator.Send(new GetPlexPathReplacementsBySourceId(Id)) + .Map(list => list.Map(ProjectToEditViewModel).ToList()); - private PlexPathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) => - new() { Id = item.Id, PlexPath = item.PlexPath, LocalPath = item.LocalPath }; + private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) => + new() { Id = item.Id, RemotePath = item.PlexPath, LocalPath = item.LocalPath }; - private void AddPathReplacement() + private IRequest> GetUpdatePathReplacementsRequest(List pathReplacements) { - var item = new PlexPathReplacementEditViewModel(); - _pathReplacements.Add(item); - _selectedItem = item; - } - - private void RemovePathReplacement(PlexPathReplacementEditViewModel item) - { - _selectedItem = null; - _pathReplacements.Remove(item); - } - - private async Task SaveChanges() - { - var items = _pathReplacements - .Map(item => new PlexPathReplacementItem(item.Id, item.PlexPath, item.LocalPath)) + var items = pathReplacements + .Map(item => new PlexPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) .ToList(); - Seq errorMessages = await Mediator.Send(new UpdatePlexPathReplacements(Id, items)).Map(e => e.LeftToSeq()); - - errorMessages.HeadOrNone().Match( - error => - { - Snackbar.Add($"Unexpected error saving path replacements: {error.Value}", Severity.Error); - Logger.LogError("Unexpected error saving path replacements: {Error}", error.Value); - }, - () => NavigationManager.NavigateTo("/media/plex")); + return new UpdatePlexPathReplacements(Id, items); } } \ No newline at end of file diff --git a/ErsatzTV/Services/EmbyService.cs b/ErsatzTV/Services/EmbyService.cs new file mode 100644 index 000000000..113d04daa --- /dev/null +++ b/ErsatzTV/Services/EmbyService.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application; +using ErsatzTV.Application.Emby.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Services +{ + public class EmbyService : BackgroundService + { + private readonly ChannelReader _channel; + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + + public EmbyService( + ChannelReader channel, + IServiceScopeFactory serviceScopeFactory, + ILogger logger) + { + _channel = channel; + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + if (!File.Exists(FileSystemLayout.EmbySecretsPath)) + { + await File.WriteAllTextAsync(FileSystemLayout.EmbySecretsPath, "{}", cancellationToken); + } + + _logger.LogInformation( + "Emby service started; secrets are at {EmbySecretsPath}", + FileSystemLayout.EmbySecretsPath); + + // synchronize sources on startup + await SynchronizeSources(new SynchronizeEmbyMediaSources(), cancellationToken); + + await foreach (IEmbyBackgroundServiceRequest request in _channel.ReadAllAsync(cancellationToken)) + { + try + { + Task requestTask; + switch (request) + { + case SynchronizeEmbyMediaSources synchronizeEmbyMediaSources: + requestTask = SynchronizeSources(synchronizeEmbyMediaSources, cancellationToken); + break; + // case SynchronizeEmbyAdminUserId synchronizeEmbyAdminUserId: + // requestTask = SynchronizeAdminUserId(synchronizeEmbyAdminUserId, cancellationToken); + // break; + case SynchronizeEmbyLibraries synchronizeEmbyLibraries: + requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, cancellationToken); + break; + case ISynchronizeEmbyLibraryById synchronizeEmbyLibraryById: + requestTask = SynchronizeEmbyLibrary(synchronizeEmbyLibraryById, cancellationToken); + break; + default: + throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}"); + } + + await requestTask; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to process Emby background service request"); + } + } + } + + private async Task SynchronizeSources( + SynchronizeEmbyMediaSources request, + CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + Either> result = await mediator.Send(request, cancellationToken); + result.Match( + sources => + { + if (sources.Any()) + { + _logger.LogInformation("Successfully synchronized emby media sources"); + } + }, + error => + { + _logger.LogWarning( + "Unable to synchronize emby media sources: {Error}", + error.Value); + }); + } + + private async Task SynchronizeLibraries( + SynchronizeEmbyLibraries request, + CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + Either result = await mediator.Send(request, cancellationToken); + result.BiIter( + _ => _logger.LogInformation( + "Successfully synchronized Emby libraries for source {MediaSourceId}", + request.EmbyMediaSourceId), + error => _logger.LogWarning( + "Unable to synchronize Emby libraries for source {MediaSourceId}: {Error}", + request.EmbyMediaSourceId, + error.Value)); + } + + // private async Task SynchronizeAdminUserId( + // SynchronizeEmbyAdminUserId request, + // CancellationToken cancellationToken) + // { + // using IServiceScope scope = _serviceScopeFactory.CreateScope(); + // IMediator mediator = scope.ServiceProvider.GetRequiredService(); + // + // Either result = await mediator.Send(request, cancellationToken); + // result.BiIter( + // _ => _logger.LogInformation( + // "Successfully synchronized Emby admin user id for source {MediaSourceId}", + // request.EmbyMediaSourceId), + // error => _logger.LogWarning( + // "Unable to synchronize Emby admin user id for source {MediaSourceId}: {Error}", + // request.EmbyMediaSourceId, + // error.Value)); + // } + + private async Task SynchronizeEmbyLibrary( + ISynchronizeEmbyLibraryById request, + CancellationToken cancellationToken) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + + Either result = await mediator.Send(request, cancellationToken); + result.BiIter( + name => _logger.LogDebug("Done synchronizing emby library {Name}", name), + error => _logger.LogWarning( + "Unable to synchronize emby library {LibraryId}: {Error}", + request.EmbyLibraryId, + error.Value)); + } + } +} diff --git a/ErsatzTV/Shared/DisconnectJellyfinDialog.razor b/ErsatzTV/Shared/DisconnectRemoteMediaSourceDialog.razor similarity index 82% rename from ErsatzTV/Shared/DisconnectJellyfinDialog.razor rename to ErsatzTV/Shared/DisconnectRemoteMediaSourceDialog.razor index d6d2442f0..1424a97d3 100644 --- a/ErsatzTV/Shared/DisconnectJellyfinDialog.razor +++ b/ErsatzTV/Shared/DisconnectRemoteMediaSourceDialog.razor @@ -4,7 +4,7 @@ + Text="@($"Do you really want to disconnect {Name}? All synchronized content will be removed.")"/> @@ -16,6 +16,9 @@ @code { + [Parameter] + public string Name { get; set; } + [CascadingParameter] MudDialogInstance MudDialog { get; set; } diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index 6fcc0fb6e..7ac643cf7 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -44,6 +44,7 @@ Channels FFmpeg Profiles + Emby Jellyfin Plex diff --git a/ErsatzTV/Shared/RemoteMediaSourceEditor.razor b/ErsatzTV/Shared/RemoteMediaSourceEditor.razor new file mode 100644 index 000000000..fa0629a56 --- /dev/null +++ b/ErsatzTV/Shared/RemoteMediaSourceEditor.razor @@ -0,0 +1,68 @@ +@using Unit = LanguageExt.Unit +@inject IMediator _mediator +@inject NavigationManager _navigationManager +@inject ISnackbar _snackbar +@inject ILogger _logger + + + @Name Media Source +
+ + + + + + + + + + Save Changes + + + + +
+
+ +@code { + + [Parameter] + public string Name { get; set; } + + [Parameter] + public Func> LoadSecrets { get; set; } + + [Parameter] + public Func>> SaveSecrets { get; set; } + + private readonly RemoteMediaSourceEditViewModel _model = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + private bool _isValid; + + protected override async Task OnParametersSetAsync() => await LoadSecrets(_model); + + protected override void OnInitialized() + { + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private async Task HandleSubmitAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + Either result = await SaveSecrets(_model); + result.Match( + _ => _navigationManager.NavigateTo($"/media/{Name.ToLowerInvariant()}"), + error => + { + _snackbar.Add(error.Value, Severity.Error); + _logger.LogError("Error saving {MediaSource} secrets: {Error}", Name, error.Value); + }); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor b/ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor new file mode 100644 index 000000000..6c7c2dddd --- /dev/null +++ b/ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor @@ -0,0 +1,112 @@ +@using ErsatzTV.Application.MediaSources +@using Unit = LanguageExt.Unit +@inject IMediator _mediator +@inject NavigationManager _navigationManager +@inject ILogger _logger +@inject ISnackbar _snackbar +@inject IEntityLocker _locker + + + + + @_source.Name Libraries + + + + + + + + + + Name + + + + + Media Kind + + + Synchronize + + + @context.Name + @context.MediaKind + + + + + + + Save Changes + + + +@code { + + [Parameter] + public int Id { get; set; } + + [Parameter] + public string Name { get; set; } + + [Parameter] + public Func>> GetMediaSourceById { get; set; } + + [Parameter] + public Func>> GetLibrariesBySourceId { get; set; } + + [Parameter] + public Func, IRequest>> GetUpdateLibraryRequest { get; set; } + + [Parameter] + public Func> SynchronizeLibraryByIdIfNeeded { get; set; } + + private RemoteMediaSourceViewModel _source; + private List _libraries; + + protected override Task OnParametersSetAsync() => LoadData(); + + private async Task LoadData() + { + Option maybeSource = await GetMediaSourceById(Id); + await maybeSource.Match( + async source => + { + _source = source; + _libraries = await GetLibrariesBySourceId(Id); + }, + () => + { + _navigationManager.NavigateTo("404"); + return Task.CompletedTask; + }); + } + + private async Task SaveChanges() + { + IRequest> request = GetUpdateLibraryRequest(_libraries); + Seq errorMessages = await _mediator.Send(request).Map(e => e.LeftToSeq()); + + await errorMessages.HeadOrNone().Match( + error => + { + _snackbar.Add($"Unexpected error saving {Name.ToLowerInvariant()} libraries: {error.Value}", Severity.Error); + _logger.LogError("Unexpected error saving {MediaSource} libraries: {Error}", Name, error.Value); + return Task.CompletedTask; + }, + async () => + { + foreach (int id in _libraries.Filter(l => l.ShouldSyncItems).Map(l => l.Id)) + { + if (_locker.LockLibrary(id)) + { + await SynchronizeLibraryByIdIfNeeded(id); + } + } + + _navigationManager.NavigateTo($"/media/{Name.ToLowerInvariant()}"); + }); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/RemoteMediaSourcePathReplacementsEditor.razor b/ErsatzTV/Shared/RemoteMediaSourcePathReplacementsEditor.razor new file mode 100644 index 000000000..3416b4f13 --- /dev/null +++ b/ErsatzTV/Shared/RemoteMediaSourcePathReplacementsEditor.razor @@ -0,0 +1,138 @@ +@using ErsatzTV.Application.MediaSources +@using Unit = LanguageExt.Unit +@inject NavigationManager _navigationManager +@inject ILogger _logger +@inject ISnackbar _snackbar +@inject IMediator _mediator + + + + + @_source.Name Path Replacements + + + + + + + + @Name Path + Local Path + + + + + + @context.RemotePath + + + + + @context.LocalPath + + + + + + + + + + + + Add Path Replacement + + + Save Changes + + + @if (_selectedItem is not null) + { +
+ + + + + + + + + +
+ } +
+ +@code { + + [Parameter] + public int Id { get; set; } + + [Parameter] + public string Name { get; set; } + + [Parameter] + public Func>> GetMediaSourceById { get; set; } + + [Parameter] + public Func>> GetPathReplacementsBySourceId { get; set; } + + [Parameter] + public Func, IRequest>> GetUpdatePathReplacementsRequest { get; set; } + + private RemoteMediaSourceViewModel _source; + private List _pathReplacements; + + private RemoteMediaSourcePathReplacementEditViewModel _selectedItem; + + protected override Task OnParametersSetAsync() => LoadData(); + + private async Task LoadData() + { + Option maybeSource = await GetMediaSourceById(Id); + await maybeSource.Match( + async source => + { + _source = source; + _pathReplacements = await GetPathReplacementsBySourceId(Id); + }, + () => + { + _navigationManager.NavigateTo("404"); + return Task.CompletedTask; + }); + } + + private void AddPathReplacement() + { + var item = new RemoteMediaSourcePathReplacementEditViewModel(); + _pathReplacements.Add(item); + _selectedItem = item; + } + + private void RemovePathReplacement(RemoteMediaSourcePathReplacementEditViewModel item) + { + _selectedItem = null; + _pathReplacements.Remove(item); + } + + private async Task SaveChanges() + { + Seq errorMessages = await _mediator.Send(GetUpdatePathReplacementsRequest(_pathReplacements)) + .Map(e => e.LeftToSeq()); + + errorMessages.HeadOrNone().Match( + error => + { + _snackbar.Add($"Unexpected error saving path replacements: {error.Value}", Severity.Error); + _logger.LogError("Unexpected error saving path replacements: {Error}", error.Value); + }, + () => _navigationManager.NavigateTo($"/media/{Name.ToLowerInvariant()}")); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/RemoteMediaSources.razor b/ErsatzTV/Shared/RemoteMediaSources.razor new file mode 100644 index 000000000..1f772bca0 --- /dev/null +++ b/ErsatzTV/Shared/RemoteMediaSources.razor @@ -0,0 +1,118 @@ +@typeparam TViewModel +@typeparam TSecrets +@using Unit = LanguageExt.Unit +@using ErsatzTV.Core.Interfaces.MediaSources +@typeparam TMediaSource +@inject IMediator _mediator +@inject IDialogService _dialog +@inject IEntityLocker _locker + + + + + @Name Media Source + + + + + + + + Name + Address + + + + @context.Name + @context.Address + +
+ + + + + + + + +
+
+
+
+ @if (_mediaSources.Any()) + { + + Disconnect @Name + + } + else + { + + Connect @Name + + } + + @if (_mediaSources.Any() && !_isAuthorized) + { + + Fix @Name Connection + + } + +
+ +@code { + + [Parameter] + public string Name { get; set; } + + [Parameter] + public IRequest> GetAllMediaSourcesCommand { get; set; } + + [Parameter] + public IRequest> DisconnectCommand { get; set; } + + [Parameter] + public IRemoteMediaSourceSecretStore SecretStore { get; set; } + + private List _mediaSources = new(); + + private bool _isAuthorized; + + protected override async Task OnParametersSetAsync() => await LoadMediaSources(); + + private async Task LoadMediaSources() + { + _isAuthorized = await SecretStore.ReadSecrets() + .Map(secrets => !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey)); + _mediaSources = await _mediator.Send(GetAllMediaSourcesCommand); + } + + private async Task Disconnect() + { + var parameters = new DialogParameters { { "Name", Name } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }; + IDialogReference dialog = _dialog.Show($"Disconnect {Name}", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + if (_locker.LockRemoteMediaSource()) + { + await _mediator.Send(DisconnectCommand); + await LoadMediaSources(); + } + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/RemoteMediaSources.razor.cs b/ErsatzTV/Shared/RemoteMediaSources.razor.cs new file mode 100644 index 000000000..233165179 --- /dev/null +++ b/ErsatzTV/Shared/RemoteMediaSources.razor.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Application.MediaSources; +using ErsatzTV.Core.MediaSources; + +namespace ErsatzTV.Shared +{ + public partial class RemoteMediaSources + where TViewModel : RemoteMediaSourceViewModel + where TSecrets : RemoteMediaSourceSecrets + { + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index e54d490ff..55cfc871f 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -8,7 +8,9 @@ using Dapper; using ErsatzTV.Application; using ErsatzTV.Application.Channels.Queries; using ErsatzTV.Core; +using ErsatzTV.Core.Emby; using ErsatzTV.Core.FFmpeg; +using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.GitHub; using ErsatzTV.Core.Interfaces.Images; @@ -27,6 +29,7 @@ using ErsatzTV.Core.Scheduling; using ErsatzTV.Formatters; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Infrastructure.Emby; using ErsatzTV.Infrastructure.GitHub; using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Jellyfin; @@ -192,6 +195,7 @@ namespace ErsatzTV AddChannel(services); AddChannel(services); AddChannel(services); + AddChannel(services); services.AddScoped(); services.AddScoped(); @@ -228,6 +232,11 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -241,10 +250,12 @@ namespace ErsatzTV return sanitizer; }); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); diff --git a/ErsatzTV/Validators/JellyfinMediaSourceEditViewModelValidator.cs b/ErsatzTV/Validators/JellyfinMediaSourceEditViewModelValidator.cs deleted file mode 100644 index 9a6e02005..000000000 --- a/ErsatzTV/Validators/JellyfinMediaSourceEditViewModelValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ErsatzTV.ViewModels; -using FluentValidation; - -namespace ErsatzTV.Validators -{ - public class JellyfinMediaSourceEditViewModelValidator : AbstractValidator - { - } -} diff --git a/ErsatzTV/Validators/JellyfinPathReplacementEditViewModelValidator.cs b/ErsatzTV/Validators/JellyfinPathReplacementEditViewModelValidator.cs deleted file mode 100644 index 5efbfaf22..000000000 --- a/ErsatzTV/Validators/JellyfinPathReplacementEditViewModelValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ErsatzTV.ViewModels; -using FluentValidation; - -namespace ErsatzTV.Validators -{ - public class JellyfinPathReplacementEditViewModelValidator : AbstractValidator - { - public JellyfinPathReplacementEditViewModelValidator() - { - RuleFor(vm => vm.JellyfinPath).NotEmpty(); - RuleFor(vm => vm.LocalPath).NotEmpty(); - } - } -} diff --git a/ErsatzTV/Validators/RemoteMediaSourceEditViewModelValidator.cs b/ErsatzTV/Validators/RemoteMediaSourceEditViewModelValidator.cs new file mode 100644 index 000000000..14810fbc4 --- /dev/null +++ b/ErsatzTV/Validators/RemoteMediaSourceEditViewModelValidator.cs @@ -0,0 +1,20 @@ +using System; +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class RemoteMediaSourceEditViewModelValidator : AbstractValidator + { + public RemoteMediaSourceEditViewModelValidator() + { + RuleFor(x => x.Address) + .NotEmpty() + .Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _)) + .WithMessage("'Address' must be a valid URL"); + + RuleFor(x => x.ApiKey) + .NotEmpty(); + } + } +} diff --git a/ErsatzTV/Validators/RemoteMediaSourcePathReplacementEditViewModelValidator.cs b/ErsatzTV/Validators/RemoteMediaSourcePathReplacementEditViewModelValidator.cs new file mode 100644 index 000000000..39465f03c --- /dev/null +++ b/ErsatzTV/Validators/RemoteMediaSourcePathReplacementEditViewModelValidator.cs @@ -0,0 +1,16 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class + RemoteMediaSourcePathReplacementEditViewModelValidator : AbstractValidator< + RemoteMediaSourcePathReplacementEditViewModel> + { + public RemoteMediaSourcePathReplacementEditViewModelValidator() + { + RuleFor(vm => vm.RemotePath).NotEmpty(); + RuleFor(vm => vm.LocalPath).NotEmpty(); + } + } +} diff --git a/ErsatzTV/ViewModels/JellyfinMediaSourceLibraryEditViewModel.cs b/ErsatzTV/ViewModels/JellyfinMediaSourceLibraryEditViewModel.cs deleted file mode 100644 index 97edfb40c..000000000 --- a/ErsatzTV/ViewModels/JellyfinMediaSourceLibraryEditViewModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -using ErsatzTV.Core.Domain; - -namespace ErsatzTV.ViewModels -{ - public class JellyfinMediaSourceLibraryEditViewModel - { - public int Id { get; set; } - public string Name { get; set; } - public LibraryMediaKind MediaKind { get; init; } - public bool ShouldSyncItems { get; set; } - } -} diff --git a/ErsatzTV/ViewModels/JellyfinMediaSourceEditViewModel.cs b/ErsatzTV/ViewModels/RemoteMediaSourceEditViewModel.cs similarity index 73% rename from ErsatzTV/ViewModels/JellyfinMediaSourceEditViewModel.cs rename to ErsatzTV/ViewModels/RemoteMediaSourceEditViewModel.cs index 2334890e7..81f7430bc 100644 --- a/ErsatzTV/ViewModels/JellyfinMediaSourceEditViewModel.cs +++ b/ErsatzTV/ViewModels/RemoteMediaSourceEditViewModel.cs @@ -1,6 +1,6 @@ namespace ErsatzTV.ViewModels { - public class JellyfinMediaSourceEditViewModel + public class RemoteMediaSourceEditViewModel { public string Address { get; set; } public string ApiKey { get; set; } diff --git a/ErsatzTV/ViewModels/PlexMediaSourceLibraryEditViewModel.cs b/ErsatzTV/ViewModels/RemoteMediaSourceLibraryEditViewModel.cs similarity index 82% rename from ErsatzTV/ViewModels/PlexMediaSourceLibraryEditViewModel.cs rename to ErsatzTV/ViewModels/RemoteMediaSourceLibraryEditViewModel.cs index 45ba27e68..0387390f6 100644 --- a/ErsatzTV/ViewModels/PlexMediaSourceLibraryEditViewModel.cs +++ b/ErsatzTV/ViewModels/RemoteMediaSourceLibraryEditViewModel.cs @@ -2,7 +2,7 @@ namespace ErsatzTV.ViewModels { - public class PlexMediaSourceLibraryEditViewModel + public class RemoteMediaSourceLibraryEditViewModel { public int Id { get; set; } public string Name { get; set; } diff --git a/ErsatzTV/ViewModels/JellyfinPathReplacementEditViewModel.cs b/ErsatzTV/ViewModels/RemoteMediaSourcePathReplacementEditViewModel.cs similarity index 54% rename from ErsatzTV/ViewModels/JellyfinPathReplacementEditViewModel.cs rename to ErsatzTV/ViewModels/RemoteMediaSourcePathReplacementEditViewModel.cs index 33e5038d4..170840ff7 100644 --- a/ErsatzTV/ViewModels/JellyfinPathReplacementEditViewModel.cs +++ b/ErsatzTV/ViewModels/RemoteMediaSourcePathReplacementEditViewModel.cs @@ -1,9 +1,9 @@ namespace ErsatzTV.ViewModels { - public class JellyfinPathReplacementEditViewModel + public class RemoteMediaSourcePathReplacementEditViewModel { public int Id { get; set; } - public string JellyfinPath { get; set; } + public string RemotePath { get; set; } public string LocalPath { get; set; } } } diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index 5af4b1829..94285b570 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -132,4 +132,4 @@ .release-notes h3 { margin-top: 20px; } -.mud-table-container { overflow-x: unset; } +.mud-table-container { overflow-x: unset; } \ No newline at end of file