From f281d9fca571b0c4a65172603592d2add0d654b9 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Mon, 1 Mar 2021 20:54:23 -0600 Subject: [PATCH] Interface improvements (#34) * fix plex library synchronization * add basic plex movie synchronization * proxy plex movie thumbnails (posters) * add plex path replacements * use transcoded plex artwork * remove unsynchronized plex movies on save; queue plex library scan on save * log plex path replacements * prefer buttons instead of menus * lock plex libraries before sync * add movie to collection from paged view * fix plex import memory use; quick add seasons/shows * quick add episode to collection * add favicon * add search page * disable plex for now --- .../Libraries/LibraryViewModel.cs | 6 + .../Libraries/LocalLibraryViewModel.cs | 3 +- ErsatzTV.Application/Libraries/Mapper.cs | 11 +- .../Libraries/PlexLibraryViewModel.cs | 7 + ...etLocalLibraries.cs => GetAllLibraries.cs} | 2 +- .../Queries/GetAllLibrariesHandler.cs | 31 + .../Queries/GetLocalLibrariesHandler.cs | 22 - ErsatzTV.Application/MediaCards/Mapper.cs | 6 + .../Queries/GetMovieCardsHandler.cs | 4 +- .../MediaCards/Queries/GetSearchCards.cs | 8 + .../Queries/GetSearchCardsHandler.cs | 25 + .../MediaCards/SearchCardResultsViewModel.cs | 8 + .../SynchronizePlexLibrariesHandler.cs | 4 +- .../Commands/SynchronizePlexLibraryById.cs | 5 +- .../SynchronizePlexLibraryByIdHandler.cs | 12 +- .../UpdatePlexLibraryPreferencesHandler.cs | 3 +- .../Commands/UpdatePlexPathReplacements.cs | 13 + .../UpdatePlexPathReplacementsHandler.cs | 65 + ErsatzTV.Application/Plex/Mapper.cs | 3 + .../Plex/PlexConnectionParametersViewModel.cs | 6 + .../Plex/PlexPathReplacementViewModel.cs | 4 + .../Queries/GetPlexConnectionParameters.cs | 9 + .../GetPlexConnectionParametersHandler.cs | 94 ++ .../GetPlexPathReplacementsBySourceId.cs | 8 + ...etPlexPathReplacementsBySourceIdHandler.cs | 26 + ...layoutItemProcessByChannelNumberHandler.cs | 62 +- .../Metadata/FallbackMetadataProviderTests.cs | 7 +- .../Domain/MediaItem/PlexMediaFile.cs | 8 + .../Domain/MediaItem/PlexMediaItemPart.cs | 12 - ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs | 1 - .../Domain/MediaSource/PlexMediaSource.cs | 1 + .../Domain/MediaSource/PlexPathReplacement.cs | 11 + ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 19 +- .../Metadata/IFallbackMetadataProvider.cs | 6 +- .../Repositories/ILibraryRepository.cs | 2 +- .../Repositories/IMediaSourceRepository.cs | 5 +- .../Repositories/ISearchRepository.cs | 11 + ErsatzTV.Core/Iptv/ChannelGuide.cs | 2 +- .../Metadata/FallbackMetadataProvider.cs | 32 +- .../Metadata/LocalMetadataProvider.cs | 42 +- ErsatzTV.Core/Plex/PlexMediaEntry.cs | 20 - ErsatzTV.Core/Plex/PlexMetadataEntry.cs | 19 - ErsatzTV.Core/Plex/PlexPartEntry.cs | 11 - .../MediaItem/PlexMediaFileConfiguration.cs | 11 + ...iguration.cs => PlexMovieConfiguration.cs} | 2 +- .../PlexMediaSourceConfiguration.cs | 5 + .../PlexPathReplacementConfiguration.cs | 11 + .../Data/Repositories/LibraryRepository.cs | 4 +- .../Repositories/MediaSourceRepository.cs | 58 +- .../Data/Repositories/MovieRepository.cs | 19 +- .../Data/Repositories/SearchRepository.cs | 48 + ErsatzTV.Infrastructure/Data/TvContext.cs | 3 +- ...210228210815_Add_PlexMediaFile.Designer.cs | 1456 ++++++++++++++++ .../20210228210815_Add_PlexMediaFile.cs | 84 + ...001541_Add_PlexPathReplacement.Designer.cs | 1491 +++++++++++++++++ .../20210301001541_Add_PlexPathReplacement.cs | 40 + .../Migrations/TvContextModelSnapshot.cs | 438 +++-- .../Plex/Models/PlexLibraryResponse.cs | 1 + .../Plex/Models/PlexPartResponse.cs | 1 - .../Plex/PlexServerApiClient.cs | 127 +- ErsatzTV/Controllers/ArtworkController.cs | 40 +- ErsatzTV/Pages/Libraries.razor | 20 +- ErsatzTV/Pages/MovieList.razor | 34 +- ErsatzTV/Pages/Playouts.razor | 31 +- ErsatzTV/Pages/PlexLibrariesEditor.razor | 20 +- .../{Shared => Pages}/PlexMediaSources.razor | 73 +- .../Pages/PlexPathReplacementsEditor.razor | 135 ++ ErsatzTV/Pages/Schedules.razor | 30 +- ErsatzTV/Pages/Search.razor | 110 ++ ErsatzTV/Pages/TelevisionEpisodeList.razor | 29 +- ErsatzTV/Pages/TelevisionSeasonList.razor | 29 +- ErsatzTV/Pages/TelevisionShowList.razor | 34 +- ErsatzTV/Pages/_Host.cshtml | 1 + ErsatzTV/Shared/MainLayout.razor | 39 +- ErsatzTV/Shared/MediaCard.razor | 10 + ErsatzTV/Shared/_Favicons.cshtml | 6 + ErsatzTV/Startup.cs | 3 +- ...exPathReplacementEditViewModelValidator.cs | 14 + .../PlexPathReplacementEditViewModel.cs | 9 + ErsatzTV/wwwroot/android-chrome-192x192.png | Bin 0 -> 3318 bytes ErsatzTV/wwwroot/android-chrome-512x512.png | Bin 0 -> 9604 bytes ErsatzTV/wwwroot/apple-touch-icon.png | Bin 0 -> 3108 bytes ErsatzTV/wwwroot/browserconfig.xml | 9 + ErsatzTV/wwwroot/css/site.css | 47 +- ErsatzTV/wwwroot/favicon-16x16.png | Bin 0 -> 1032 bytes ErsatzTV/wwwroot/favicon-32x32.png | Bin 0 -> 1440 bytes ErsatzTV/wwwroot/favicon.ico | Bin 0 -> 15086 bytes ErsatzTV/wwwroot/mstile-150x150.png | Bin 0 -> 2809 bytes ErsatzTV/wwwroot/site.webmanifest | 19 + 89 files changed, 4726 insertions(+), 471 deletions(-) create mode 100644 ErsatzTV.Application/Libraries/LibraryViewModel.cs create mode 100644 ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs rename ErsatzTV.Application/Libraries/Queries/{GetLocalLibraries.cs => GetAllLibraries.cs} (57%) create mode 100644 ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs delete mode 100644 ErsatzTV.Application/Libraries/Queries/GetLocalLibrariesHandler.cs create mode 100644 ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs create mode 100644 ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs create mode 100644 ErsatzTV.Application/MediaCards/SearchCardResultsViewModel.cs create mode 100644 ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs create mode 100644 ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs create mode 100644 ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs create mode 100644 ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs create mode 100644 ErsatzTV.Application/Plex/Queries/GetPlexConnectionParameters.cs create mode 100644 ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs create mode 100644 ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs create mode 100644 ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs create mode 100644 ErsatzTV.Core/Domain/MediaItem/PlexMediaFile.cs delete mode 100644 ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs create mode 100644 ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs delete mode 100644 ErsatzTV.Core/Plex/PlexMediaEntry.cs delete mode 100644 ErsatzTV.Core/Plex/PlexMetadataEntry.cs delete mode 100644 ErsatzTV.Core/Plex/PlexPartEntry.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMediaFileConfiguration.cs rename ErsatzTV.Infrastructure/Data/Configurations/MediaItem/{PlexMovieMediaItemConfiguration.cs => PlexMovieConfiguration.cs} (77%) create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.Designer.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs rename ErsatzTV/{Shared => Pages}/PlexMediaSources.razor (56%) create mode 100644 ErsatzTV/Pages/PlexPathReplacementsEditor.razor create mode 100644 ErsatzTV/Pages/Search.razor create mode 100644 ErsatzTV/Shared/_Favicons.cshtml create mode 100644 ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs create mode 100644 ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs create mode 100644 ErsatzTV/wwwroot/android-chrome-192x192.png create mode 100644 ErsatzTV/wwwroot/android-chrome-512x512.png create mode 100644 ErsatzTV/wwwroot/apple-touch-icon.png create mode 100644 ErsatzTV/wwwroot/browserconfig.xml create mode 100644 ErsatzTV/wwwroot/favicon-16x16.png create mode 100644 ErsatzTV/wwwroot/favicon-32x32.png create mode 100644 ErsatzTV/wwwroot/favicon.ico create mode 100644 ErsatzTV/wwwroot/mstile-150x150.png create mode 100644 ErsatzTV/wwwroot/site.webmanifest diff --git a/ErsatzTV.Application/Libraries/LibraryViewModel.cs b/ErsatzTV.Application/Libraries/LibraryViewModel.cs new file mode 100644 index 000000000..8fbe74c0f --- /dev/null +++ b/ErsatzTV.Application/Libraries/LibraryViewModel.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Libraries +{ + public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind); +} diff --git a/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs b/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs index 3e37ecfb1..5c51fc60a 100644 --- a/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs +++ b/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs @@ -2,5 +2,6 @@ namespace ErsatzTV.Application.Libraries { - public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind); + public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind) + : LibraryViewModel("Local", Id, Name, MediaKind); } diff --git a/ErsatzTV.Application/Libraries/Mapper.cs b/ErsatzTV.Application/Libraries/Mapper.cs index 872a1a163..cee82d388 100644 --- a/ErsatzTV.Application/Libraries/Mapper.cs +++ b/ErsatzTV.Application/Libraries/Mapper.cs @@ -1,9 +1,18 @@ -using ErsatzTV.Core.Domain; +using System; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Application.Libraries { internal static class Mapper { + public static LibraryViewModel ProjectToViewModel(Library library) => + library switch + { + LocalLibrary l => ProjectToViewModel(l), + PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind), + _ => throw new ArgumentOutOfRangeException(nameof(library)) + }; + public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) => new(library.Id, library.Name, library.MediaKind); diff --git a/ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs b/ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs new file mode 100644 index 000000000..05d4be639 --- /dev/null +++ b/ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Libraries +{ + public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind) + : LibraryViewModel("Plex", Id, Name, MediaKind); +} diff --git a/ErsatzTV.Application/Libraries/Queries/GetLocalLibraries.cs b/ErsatzTV.Application/Libraries/Queries/GetAllLibraries.cs similarity index 57% rename from ErsatzTV.Application/Libraries/Queries/GetLocalLibraries.cs rename to ErsatzTV.Application/Libraries/Queries/GetAllLibraries.cs index 6abe74f5c..89ee551a0 100644 --- a/ErsatzTV.Application/Libraries/Queries/GetLocalLibraries.cs +++ b/ErsatzTV.Application/Libraries/Queries/GetAllLibraries.cs @@ -3,5 +3,5 @@ using MediatR; namespace ErsatzTV.Application.Libraries.Queries { - public record GetLocalLibraries : IRequest>; + public record GetAllLibraries : IRequest>; } diff --git a/ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs new file mode 100644 index 000000000..7672ae208 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Libraries.Mapper; + +namespace ErsatzTV.Application.Libraries.Queries +{ + public class GetAllLibrariesHandler : IRequestHandler> + { + private readonly ILibraryRepository _libraryRepository; + + public GetAllLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository; + + public Task> Handle(GetAllLibraries request, CancellationToken cancellationToken) => + _libraryRepository.GetAll() + .Map(list => list.Filter(ShouldIncludeLibrary).Map(ProjectToViewModel).ToList()); + + private static bool ShouldIncludeLibrary(Library library) => + library switch + { + LocalLibrary => true, + PlexLibrary plex => plex.ShouldSyncItems, + _ => false + }; + } +} diff --git a/ErsatzTV.Application/Libraries/Queries/GetLocalLibrariesHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetLocalLibrariesHandler.cs deleted file mode 100644 index 0ea7d2752..000000000 --- a/ErsatzTV.Application/Libraries/Queries/GetLocalLibrariesHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -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.Libraries.Mapper; - -namespace ErsatzTV.Application.Libraries.Queries -{ - public class GetLocalLibrariesHandler : IRequestHandler> - { - private readonly ILibraryRepository _libraryRepository; - - public GetLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository; - - public Task> - Handle(GetLocalLibraries request, CancellationToken cancellationToken) => - _libraryRepository.GetAllLocal().Map(list => list.Map(ProjectToViewModel).ToList()); - } -} diff --git a/ErsatzTV.Application/MediaCards/Mapper.cs b/ErsatzTV.Application/MediaCards/Mapper.cs index b4161db16..b0fd36993 100644 --- a/ErsatzTV.Application/MediaCards/Mapper.cs +++ b/ErsatzTV.Application/MediaCards/Mapper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using ErsatzTV.Core.Domain; using static LanguageExt.Prelude; @@ -56,6 +57,11 @@ namespace ErsatzTV.Application.MediaCards collection.MediaItems.OfType().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head())) .ToList()); + internal static SearchCardResultsViewModel ProjectToSearchResults(List items) => + new( + items.OfType().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(), + items.OfType().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList()); + private static string GetSeasonName(int number) => number == 0 ? "Specials" : $"Season {number}"; diff --git a/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs index 1a55618ce..eed22c7cf 100644 --- a/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs +++ b/ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs @@ -16,9 +16,7 @@ namespace ErsatzTV.Application.MediaCards.Queries public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository; - public async Task Handle( - GetMovieCards request, - CancellationToken cancellationToken) + public async Task Handle(GetMovieCards request, CancellationToken cancellationToken) { int count = await _movieRepository.GetMovieCount(); diff --git a/ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs b/ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs new file mode 100644 index 000000000..53e47ca99 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public record GetSearchCards(string Query) : IRequest>; +} diff --git a/ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs new file mode 100644 index 000000000..3d0724740 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.MediaCards.Mapper; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.MediaCards.Queries +{ + public class GetSearchCardsHandler : IRequestHandler> + { + private readonly ISearchRepository _searchRepository; + + public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository; + + public Task> Handle( + GetSearchCards request, + CancellationToken cancellationToken) => + Try(_searchRepository.SearchMediaItems(request.Query)).Sequence() + .Map(ProjectToSearchResults) + .Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}"))); + } +} diff --git a/ErsatzTV.Application/MediaCards/SearchCardResultsViewModel.cs b/ErsatzTV.Application/MediaCards/SearchCardResultsViewModel.cs new file mode 100644 index 000000000..e84f3c413 --- /dev/null +++ b/ErsatzTV.Application/MediaCards/SearchCardResultsViewModel.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.MediaCards +{ + public record SearchCardResultsViewModel( + List MovieCards, + List ShowCards); +} diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs index 77d241b31..799d1dbab 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs @@ -78,8 +78,8 @@ namespace ErsatzTV.Application.Plex.Commands var existing = connectionParameters.PlexMediaSource.Libraries.OfType().ToList(); var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList(); var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList(); - existing.AddRange(toAdd); - toRemove.ForEach(c => existing.Remove(c)); + connectionParameters.PlexMediaSource.Libraries.AddRange(toAdd); + toRemove.ForEach(c => connectionParameters.PlexMediaSource.Libraries.Remove(c)); return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource); }, diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs index 46e11ab55..80c612a4c 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs @@ -6,19 +6,18 @@ namespace ErsatzTV.Application.Plex.Commands { public interface ISynchronizePlexLibraryById : IRequest>, IBackgroundServiceRequest { - int PlexMediaSourceId { get; } int PlexLibraryId { get; } bool ForceScan { get; } } public record SynchronizePlexLibraryByIdIfNeeded - (int PlexMediaSourceId, int PlexLibraryId) : ISynchronizePlexLibraryById + (int PlexLibraryId) : ISynchronizePlexLibraryById { public bool ForceScan => false; } public record ForceSynchronizePlexLibraryById - (int PlexMediaSourceId, int PlexLibraryId) : ISynchronizePlexLibraryById + (int PlexLibraryId) : ISynchronizePlexLibraryById { public bool ForceScan => true; } diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs index 3d9d82a6e..e538408a7 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs @@ -82,7 +82,7 @@ namespace ErsatzTV.Application.Plex.Commands parameters.Library.Name); } - // _entityLocker.UnlockMediaSource(parameters.MediaSource.Id); + _entityLocker.UnlockLibrary(parameters.Library.Id); return Unit.Default; } @@ -103,8 +103,10 @@ namespace ErsatzTV.Application.Plex.Commands private Task> PlexMediaSourceMustExist( ISynchronizePlexLibraryById request) => - _mediaSourceRepository.GetPlex(request.PlexMediaSourceId) - .Map(v => v.ToValidation($"Plex media source {request.PlexMediaSourceId} does not exist.")); + _mediaSourceRepository.GetPlexByLibraryId(request.PlexLibraryId) + .Map( + v => v.ToValidation( + $"Plex media source for library {request.PlexLibraryId} does not exist.")); private Validation MediaSourceMustHaveActiveConnection( PlexMediaSource plexMediaSource) @@ -134,9 +136,7 @@ namespace ErsatzTV.Application.Plex.Commands PlexLibrary Library, bool ForceScan); - private record ConnectionParameters( - PlexMediaSource PlexMediaSource, - PlexConnection ActiveConnection) + private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection) { public PlexServerAuthToken PlexServerAuthToken { get; set; } } diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs index 831811b35..ee81b4962 100644 --- a/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; @@ -20,7 +21,7 @@ namespace ErsatzTV.Application.Plex.Commands UpdatePlexLibraryPreferences request, CancellationToken cancellationToken) { - IEnumerable toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id); + var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList(); await _mediaSourceRepository.DisablePlexLibrarySync(toDisable); IEnumerable toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id); diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs new file mode 100644 index 000000000..cb4319e02 --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using LanguageExt; + +namespace ErsatzTV.Application.Plex.Commands +{ + public record UpdatePlexPathReplacements + ( + int PlexMediaSourceId, + List PathReplacements) : MediatR.IRequest>; + + public record PlexPathReplacementItem(int Id, string PlexPath, string LocalPath); +} diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs new file mode 100644 index 000000000..35abbcbf0 --- /dev/null +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs @@ -0,0 +1,65 @@ +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; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Plex.Commands +{ + public class + UpdatePlexPathReplacementsHandler : MediatR.IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public UpdatePlexPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + UpdatePlexPathReplacements request, + CancellationToken cancellationToken) => + Validate(request) + .MapT(pms => MergePathReplacements(request, pms)) + .Bind(v => v.ToEitherAsync()); + + private Task MergePathReplacements(UpdatePlexPathReplacements request, PlexMediaSource plexMediaSource) + { + plexMediaSource.PathReplacements ??= new List(); + + var incoming = request.PathReplacements.Map(Project).ToList(); + + var toAdd = incoming.Filter(r => r.Id < 1).ToList(); + var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList(); + var toUpdate = incoming.Except(toAdd).ToList(); + + plexMediaSource.PathReplacements.AddRange(toAdd); + toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr)); + foreach (PlexPathReplacement pathReplacement in toUpdate) + { + Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id)) + .IfSome( + pr => + { + pr.PlexPath = pathReplacement.PlexPath; + pr.LocalPath = pathReplacement.LocalPath; + }); + } + + return _mediaSourceRepository.Update(plexMediaSource).ToUnit(); + } + + private static PlexPathReplacement Project(PlexPathReplacementItem vm) => + new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath }; + + private Task> Validate(UpdatePlexPathReplacements request) => + PlexMediaSourceMustExist(request); + + private Task> PlexMediaSourceMustExist( + UpdatePlexPathReplacements request) => + _mediaSourceRepository.GetPlex(request.PlexMediaSourceId) + .Map(v => v.ToValidation($"Plex media source {request.PlexMediaSourceId} does not exist.")); + } +} diff --git a/ErsatzTV.Application/Plex/Mapper.cs b/ErsatzTV.Application/Plex/Mapper.cs index 8c80e28f0..c8b2a1d37 100644 --- a/ErsatzTV.Application/Plex/Mapper.cs +++ b/ErsatzTV.Application/Plex/Mapper.cs @@ -14,5 +14,8 @@ namespace ErsatzTV.Application.Plex internal static PlexLibraryViewModel ProjectToViewModel(PlexLibrary library) => new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems); + + internal static PlexPathReplacementViewModel ProjectToViewModel(PlexPathReplacement pathReplacement) => + new(pathReplacement.Id, pathReplacement.PlexPath, pathReplacement.LocalPath); } } diff --git a/ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs b/ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs new file mode 100644 index 000000000..d01b17e50 --- /dev/null +++ b/ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs @@ -0,0 +1,6 @@ +using System; + +namespace ErsatzTV.Application.Plex +{ + public record PlexConnectionParametersViewModel(Uri Uri, string AuthToken); +} diff --git a/ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs b/ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs new file mode 100644 index 000000000..a8809250c --- /dev/null +++ b/ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Plex +{ + public record PlexPathReplacementViewModel(int Id, string PlexPath, string LocalPath); +} diff --git a/ErsatzTV.Application/Plex/Queries/GetPlexConnectionParameters.cs b/ErsatzTV.Application/Plex/Queries/GetPlexConnectionParameters.cs new file mode 100644 index 000000000..3607ee8db --- /dev/null +++ b/ErsatzTV.Application/Plex/Queries/GetPlexConnectionParameters.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Plex.Queries +{ + public record GetPlexConnectionParameters + (int PlexMediaSourceId) : IRequest>; +} diff --git a/ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs b/ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs new file mode 100644 index 000000000..0ea0e867a --- /dev/null +++ b/ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Plex; +using LanguageExt; +using MediatR; +using Microsoft.Extensions.Caching.Memory; + +namespace ErsatzTV.Application.Plex.Queries +{ + public class GetPlexConnectionParametersHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IMemoryCache _memoryCache; + private readonly IPlexSecretStore _plexSecretStore; + + public GetPlexConnectionParametersHandler( + IMemoryCache memoryCache, + IMediaSourceRepository mediaSourceRepository, + IPlexSecretStore plexSecretStore) + { + _memoryCache = memoryCache; + _mediaSourceRepository = mediaSourceRepository; + _plexSecretStore = plexSecretStore; + } + + public async Task> Handle( + GetPlexConnectionParameters request, + CancellationToken cancellationToken) + { + if (_memoryCache.TryGetValue(request, out PlexConnectionParametersViewModel parameters)) + { + return parameters; + } + + Either maybeParameters = + await Validate(request) + .MapT( + cp => new PlexConnectionParametersViewModel( + new Uri(cp.ActiveConnection.Uri), + cp.PlexServerAuthToken.AuthToken)) + .Map(v => v.ToEither()); + + return maybeParameters.Match( + p => + { + _memoryCache.Set(request, p, TimeSpan.FromHours(1)); + return maybeParameters; + }, + error => error); + } + + private Task> Validate(GetPlexConnectionParameters request) => + PlexMediaSourceMustExist(request) + .BindT(MediaSourceMustHaveActiveConnection) + .BindT(MediaSourceMustHaveToken); + + private Task> PlexMediaSourceMustExist( + GetPlexConnectionParameters request) => + _mediaSourceRepository.GetPlex(request.PlexMediaSourceId) + .Map( + v => v.ToValidation( + $"Plex media source {request.PlexMediaSourceId} does not exist.")); + + private Validation MediaSourceMustHaveActiveConnection( + PlexMediaSource plexMediaSource) + { + Option maybeConnection = + plexMediaSource.Connections.SingleOrDefault(c => c.IsActive); + return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection)) + .ToValidation("Plex media source requires an active connection"); + } + + private async Task> MediaSourceMustHaveToken( + ConnectionParameters connectionParameters) + { + Option maybeToken = await + _plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier); + return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token }) + .ToValidation("Plex media source requires a token"); + } + + private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection) + { + public PlexServerAuthToken PlexServerAuthToken { get; set; } + } + } +} diff --git a/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs new file mode 100644 index 000000000..5246d3d08 --- /dev/null +++ b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Plex.Queries +{ + public record GetPlexPathReplacementsBySourceId + (int PlexMediaSourceId) : IRequest>; +} diff --git a/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs new file mode 100644 index 000000000..d5aa31cd3 --- /dev/null +++ b/ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.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.Plex.Mapper; + +namespace ErsatzTV.Application.Plex.Queries +{ + public class GetPlexPathReplacementsBySourceIdHandler : IRequestHandler> + { + private readonly IMediaSourceRepository _mediaSourceRepository; + + public GetPlexPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) => + _mediaSourceRepository = mediaSourceRepository; + + public Task> Handle( + GetPlexPathReplacementsBySourceId request, + CancellationToken cancellationToken) => + _mediaSourceRepository.GetPlexPathReplacements(request.PlexMediaSourceId) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 30f653acb..218fb4817 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -1,11 +1,15 @@ using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; +using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Streaming.Queries { @@ -13,17 +17,23 @@ namespace ErsatzTV.Application.Streaming.Queries GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler { private readonly FFmpegProcessService _ffmpegProcessService; + private readonly ILogger _logger; + private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IPlayoutRepository _playoutRepository; public GetPlayoutItemProcessByChannelNumberHandler( IChannelRepository channelRepository, IConfigElementRepository configElementRepository, IPlayoutRepository playoutRepository, - FFmpegProcessService ffmpegProcessService) + IMediaSourceRepository mediaSourceRepository, + FFmpegProcessService ffmpegProcessService, + ILogger logger) : base(channelRepository, configElementRepository) { _playoutRepository = playoutRepository; + _mediaSourceRepository = mediaSourceRepository; _ffmpegProcessService = ffmpegProcessService; + _logger = logger; } protected override async Task> GetProcess( @@ -33,9 +43,32 @@ namespace ErsatzTV.Application.Streaming.Queries { DateTimeOffset now = DateTimeOffset.Now; Option maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now); - return maybePlayoutItem.Match>( - playoutItem => _ffmpegProcessService.ForPlayoutItem(ffmpegPath, channel, playoutItem, now), - () => + return await maybePlayoutItem.Match>>( + async playoutItem => + { + MediaVersion version = playoutItem.MediaItem switch + { + Movie m => m.MediaVersions.Head(), + Episode e => e.MediaVersions.Head(), + _ => throw new ArgumentOutOfRangeException(nameof(playoutItem)) + }; + + MediaFile file = version.MediaFiles.Head(); + string path = file.Path; + if (playoutItem.MediaItem is PlexMovie plexMovie) + { + path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path); + } + + return _ffmpegProcessService.ForPlayoutItem( + ffmpegPath, + channel, + version, + path, + playoutItem.StartOffset, + now); + }, + async () => { if (channel.FFmpegProfile.Transcode) { @@ -46,5 +79,26 @@ namespace ErsatzTV.Application.Streaming.Queries $"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'"); }); } + + private async Task GetReplacementPlexPath(int libraryPathId, string path) + { + List replacements = + await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId); + // TODO: this might barf mixing platforms (i.e. plex on linux, etv on windows) + Option maybeReplacement = replacements + .SingleOrDefault(r => path.StartsWith(r.PlexPath + Path.DirectorySeparatorChar)); + return maybeReplacement.Match( + replacement => + { + string finalPath = path.Replace(replacement.PlexPath, replacement.LocalPath); + _logger.LogInformation( + "Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}", + replacement.PlexPath, + replacement.LocalPath, + finalPath); + return finalPath; + }, + () => path); + } } } diff --git a/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs b/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs index dc036508c..7b0ef2e58 100644 --- a/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs @@ -10,6 +10,11 @@ namespace ErsatzTV.Core.Tests.Metadata [TestFixture] public class FallbackMetadataProviderTests { + [SetUp] + public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider(); + + private FallbackMetadataProvider _fallbackMetadataProvider; + [Test] [TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)] @@ -39,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Metadata 2)] public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode) { - (EpisodeMetadata metadata, int episodeNumber) = FallbackMetadataProvider.GetFallbackMetadata( + (EpisodeMetadata metadata, int episodeNumber) = _fallbackMetadataProvider.GetFallbackMetadata( new Episode { LibraryPath = new LibraryPath(), diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexMediaFile.cs b/ErsatzTV.Core/Domain/MediaItem/PlexMediaFile.cs new file mode 100644 index 000000000..ee902d82e --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/PlexMediaFile.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexMediaFile : MediaFile + { + public int PlexId { get; set; } + public string Key { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs b/ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs deleted file mode 100644 index 83b803bdb..000000000 --- a/ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ErsatzTV.Core.Domain -{ - public class PlexMediaItemPart - { - public int Id { get; set; } - public int PlexId { get; set; } - public string Key { get; set; } - public int Duration { get; set; } - public string File { get; set; } - public int Size { get; set; } - } -} diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs b/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs index 326ced9dc..37c100374 100644 --- a/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs +++ b/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs @@ -3,6 +3,5 @@ public class PlexMovie : Movie { public string Key { get; set; } - public PlexMediaItemPart Part { get; set; } } } diff --git a/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs b/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs index 4206c93ff..cac53f0e1 100644 --- a/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs +++ b/ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs @@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Domain // public bool IsOwned { get; set; } public List Connections { get; set; } + public List PathReplacements { get; set; } } } diff --git a/ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs b/ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs new file mode 100644 index 000000000..f5e5c71ff --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs @@ -0,0 +1,11 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexPathReplacement + { + public int Id { get; set; } + public string PlexPath { get; set; } + public string LocalPath { get; set; } + public int PlexMediaSourceId { get; set; } + public PlexMediaSource PlexMediaSource { get; set; } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 90f45c503..6bbb0405d 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -15,32 +15,25 @@ namespace ErsatzTV.Core.FFmpeg public Process ForPlayoutItem( string ffmpegPath, Channel channel, - PlayoutItem item, + MediaVersion version, + string path, + DateTimeOffset start, DateTimeOffset now) { - MediaVersion version = item.MediaItem switch - { - Movie m => m.MediaVersions.Head(), - Episode e => e.MediaVersions.Head(), - _ => throw new ArgumentOutOfRangeException(nameof(item)) - }; - FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings( channel.StreamingMode, channel.FFmpegProfile, version, - item.StartOffset, + start, now); - MediaFile file = version.MediaFiles.Head(); - FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath) .WithThreads(playbackSettings.ThreadCount) .WithQuiet() .WithFormatFlags(playbackSettings.FormatFlags) .WithRealtimeOutput(playbackSettings.RealtimeOutput) .WithSeek(playbackSettings.StreamSeek) - .WithInput(file.Path); + .WithInput(path); playbackSettings.ScaledSize.Match( scaledSize => @@ -86,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg return builder.WithPlaybackArgs(playbackSettings) .WithMetadata(channel) .WithFormat("mpegts") - .WithDuration(item.Start + version.Duration - now) + .WithDuration(start + version.Duration - now) .WithPipe() .Build(); } diff --git a/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs index 52125561c..64121a0b6 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs @@ -1,9 +1,13 @@ -using ErsatzTV.Core.Domain; +using System; +using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Interfaces.Metadata { public interface IFallbackMetadataProvider { ShowMetadata GetFallbackMetadataForShow(string showFolder); + Tuple GetFallbackMetadata(Episode episode); + MovieMetadata GetFallbackMetadata(Movie movie); + string GetSortTitle(string title); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs index 8ecca06fa..961e57e51 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs @@ -10,7 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task Add(LibraryPath libraryPath); Task> Get(int libraryId); Task> GetLocal(int libraryId); - Task> GetAllLocal(); + Task> GetAll(); Task UpdateLastScan(Library library); Task> GetLocalPaths(int libraryId); Task> GetPath(int libraryPathId); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs index 3828cc084..74f52762a 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs @@ -12,15 +12,18 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> GetAll(); Task> GetAllPlex(); Task> GetPlexLibraries(int plexMediaSourceId); + Task> GetPlexPathReplacements(int plexMediaSourceId); Task> GetPlexLibrary(int plexLibraryId); Task> Get(int id); Task> GetPlex(int id); + Task> GetPlexByLibraryId(int plexLibraryId); + Task> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId); Task CountMediaItems(int id); Task Update(LocalMediaSource localMediaSource); Task Update(PlexMediaSource plexMediaSource); Task Update(PlexLibrary plexMediaSourceLibrary); Task Delete(int id); - Task DisablePlexLibrarySync(IEnumerable libraryIds); + Task DisablePlexLibrarySync(List libraryIds); Task EnablePlexLibrarySync(IEnumerable libraryIds); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs new file mode 100644 index 000000000..ab86588a8 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface ISearchRepository + { + public Task> SearchMediaItems(string query); + } +} diff --git a/ErsatzTV.Core/Iptv/ChannelGuide.cs b/ErsatzTV.Core/Iptv/ChannelGuide.cs index b155dd211..8743133b1 100644 --- a/ErsatzTV.Core/Iptv/ChannelGuide.cs +++ b/ErsatzTV.Core/Iptv/ChannelGuide.cs @@ -108,7 +108,7 @@ namespace ErsatzTV.Core.Iptv xml.WriteAttributeString("system", "xmltv_ns"); xml.WriteString($"{s - 1}.{e - 1}.0/1"); xml.WriteEndElement(); // episode-num - + xml.WriteStartElement("episode-num"); xml.WriteAttributeString("system", "onscreen"); xml.WriteString($"S{s:00}E{e:00}"); diff --git a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs index 27ae51ebf..aa911336d 100644 --- a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs @@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Metadata return GetTelevisionShowMetadata(fileName, metadata); } - public static Tuple GetFallbackMetadata(Episode episode) + public Tuple GetFallbackMetadata(Episode episode) { string path = episode.MediaVersions.Head().MediaFiles.Head().Path; string fileName = Path.GetFileName(path); @@ -26,7 +26,7 @@ namespace ErsatzTV.Core.Metadata return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0); } - public static MovieMetadata GetFallbackMetadata(Movie movie) + public MovieMetadata GetFallbackMetadata(Movie movie) { string path = movie.MediaVersions.Head().MediaFiles.Head().Path; string fileName = Path.GetFileName(path); @@ -34,8 +34,28 @@ namespace ErsatzTV.Core.Metadata return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata; } + + public string GetSortTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return title; + } + + if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) + { + return title.Substring(4); + } + + if (title.StartsWith("Æ")) + { + return title.Replace("Æ", "E"); + } + + return title; + } - private static Tuple GetEpisodeMetadata(string fileName, EpisodeMetadata metadata) + private Tuple GetEpisodeMetadata(string fileName, EpisodeMetadata metadata) { try { @@ -55,7 +75,7 @@ namespace ErsatzTV.Core.Metadata return Tuple(metadata, 0); } - private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata) + private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata) { try { @@ -76,9 +96,7 @@ namespace ErsatzTV.Core.Metadata return metadata; } - private static ShowMetadata GetTelevisionShowMetadata( - string fileName, - ShowMetadata metadata) + private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata) { try { diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index 0089d519f..3caa326a6 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Metadata () => { ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder); - metadata.SortTitle = GetSortTitle(metadata.Title); + metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title); return metadata; }); } @@ -73,9 +73,9 @@ namespace ErsatzTV.Core.Metadata public Task RefreshFallbackMetadata(MediaItem mediaItem) => mediaItem switch { - Episode e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e)) + Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)) .ToUnit(), - Movie m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), + Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(), _ => Task.FromResult(Unit.Default) }; @@ -100,11 +100,11 @@ namespace ErsatzTV.Core.Metadata existing.OriginalTitle = metadata.OriginalTitle; existing.ReleaseDate = metadata.ReleaseDate; existing.Year = metadata.Year; - existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title); + existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title); }, () => { - metadata.SortTitle ??= GetSortTitle(metadata.Title); + metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title); episode.EpisodeMetadata = new List { metadata }; }); @@ -126,11 +126,11 @@ namespace ErsatzTV.Core.Metadata existing.OriginalTitle = metadata.OriginalTitle; existing.ReleaseDate = metadata.ReleaseDate; existing.Year = metadata.Year; - existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title); + existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title); }, () => { - metadata.SortTitle ??= GetSortTitle(metadata.Title); + metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title); movie.MovieMetadata = new List { metadata }; }); @@ -152,11 +152,11 @@ namespace ErsatzTV.Core.Metadata existing.OriginalTitle = metadata.OriginalTitle; existing.ReleaseDate = metadata.ReleaseDate; existing.Year = metadata.Year; - existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title); + existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title); }, () => { - metadata.SortTitle ??= GetSortTitle(metadata.Title); + metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title); show.ShowMetadata = new List { metadata }; }); @@ -247,7 +247,7 @@ namespace ErsatzTV.Core.Metadata catch (Exception ex) { _logger.LogInformation(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName); - return FallbackMetadataProvider.GetFallbackMetadata(episode); + return _fallbackMetadataProvider.GetFallbackMetadata(episode); } } @@ -274,7 +274,7 @@ namespace ErsatzTV.Core.Metadata catch (Exception ex) { _logger.LogInformation(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName); - return FallbackMetadataProvider.GetFallbackMetadata(mediaItem); + return _fallbackMetadataProvider.GetFallbackMetadata(mediaItem); } } @@ -293,26 +293,6 @@ namespace ErsatzTV.Core.Metadata return null; } - private static string GetSortTitle(string title) - { - if (string.IsNullOrWhiteSpace(title)) - { - return title; - } - - if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) - { - return title.Substring(4); - } - - if (title.StartsWith("Æ")) - { - return title.Replace("Æ", "E"); - } - - return title; - } - [XmlRoot("movie")] public class MovieNfo { diff --git a/ErsatzTV.Core/Plex/PlexMediaEntry.cs b/ErsatzTV.Core/Plex/PlexMediaEntry.cs deleted file mode 100644 index cfe062457..000000000 --- a/ErsatzTV.Core/Plex/PlexMediaEntry.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; - -namespace ErsatzTV.Core.Plex -{ - public class PlexMediaEntry - { - public int Id { get; set; } - public int Duration { get; set; } - public int Bitrate { get; set; } - public int Width { get; set; } - public int Height { get; set; } - public double AspectRatio { get; set; } - public int AudioChannels { get; set; } - public string AudioCodec { get; set; } - public string VideoCodec { get; set; } - public string Container { get; set; } - public string VideoFrameRate { get; set; } - public List Part { get; set; } - } -} diff --git a/ErsatzTV.Core/Plex/PlexMetadataEntry.cs b/ErsatzTV.Core/Plex/PlexMetadataEntry.cs deleted file mode 100644 index f5089fcee..000000000 --- a/ErsatzTV.Core/Plex/PlexMetadataEntry.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; - -namespace ErsatzTV.Core.Plex -{ - public class PlexMetadataEntry - { - public string Key { get; set; } - public string Title { get; set; } - public string Summary { get; set; } - public int Year { get; set; } - public string Tagline { get; set; } - public string Thumb { get; set; } - public string Art { get; set; } - public string OriginallyAvailableAt { get; set; } - public int AddedAt { get; set; } - public int UpdatedAt { get; set; } - public List Media { get; set; } - } -} diff --git a/ErsatzTV.Core/Plex/PlexPartEntry.cs b/ErsatzTV.Core/Plex/PlexPartEntry.cs deleted file mode 100644 index bd6b90730..000000000 --- a/ErsatzTV.Core/Plex/PlexPartEntry.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ErsatzTV.Core.Plex -{ - public class PlexPartEntry - { - public int Id { get; set; } - public string Key { get; set; } - public int Duration { get; set; } - public string File { get; set; } - public int Size { get; set; } - } -} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMediaFileConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMediaFileConfiguration.cs new file mode 100644 index 000000000..8f7ef0fcc --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMediaFileConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexMediaFileConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexMediaFile"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieMediaItemConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieConfiguration.cs similarity index 77% rename from ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieMediaItemConfiguration.cs rename to ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieConfiguration.cs index ea5269f16..454482111 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieMediaItemConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieConfiguration.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace ErsatzTV.Infrastructure.Data.Configurations { - public class PlexMovieMediaItemConfiguration : IEntityTypeConfiguration + public class PlexMovieConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexMovie"); } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs index 990521c73..ed9cc3a06 100644 --- a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs @@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations .WithOne(c => c.PlexMediaSource) .HasForeignKey(c => c.PlexMediaSourceId) .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(s => s.PathReplacements) + .WithOne(r => r.PlexMediaSource) + .HasForeignKey(r => r.PlexMediaSourceId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs new file mode 100644 index 000000000..33e5a8c1c --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexPathReplacementConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexPathReplacement"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs index 340c88e97..0fb0a901d 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs @@ -49,10 +49,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Map(Optional); } - public Task> GetAllLocal() + public Task> GetAll() { using TvContext context = _dbContextFactory.CreateDbContext(); - return context.LocalLibraries.ToListAsync(); + return context.Libraries.ToListAsync(); } public Task UpdateLastScan(Library library) => _dbConnection.ExecuteAsync( diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index b012e461a..fc21f44dd 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -68,10 +68,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .ToListAsync(); } + public Task> GetPlexPathReplacements(int plexMediaSourceId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.PlexPathReplacements + .Filter(r => r.PlexMediaSourceId == plexMediaSourceId) + .ToListAsync(); + } + public Task> GetPlexLibrary(int plexLibraryId) { using TvContext context = _dbContextFactory.CreateDbContext(); return context.PlexLibraries + .Include(l => l.Paths) .OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579 .SingleOrDefaultAsync(l => l.Id == plexLibraryId) .Map(Optional); @@ -92,11 +101,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return context.PlexMediaSources .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 Task> GetPlexByLibraryId(int plexLibraryId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.PlexMediaSources + .Include(p => p.Connections) + .Include(p => p.Libraries) + .Where(p => p.Libraries.Any(l => l.Id == plexLibraryId)) + .SingleOrDefaultAsync() + .Map(Optional); + } + + public Task> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.PlexPathReplacements + .FromSqlRaw( + @"select ppr.* from LibraryPath lp + inner join PlexLibrary pl ON pl.Id = lp.LibraryId + inner join Library l ON l.Id = pl.Id + inner join PlexPathReplacement ppr on ppr.PlexMediaSourceId = l.MediaSourceId + where lp.Id = {0}", + plexLibraryPathId) + .ToListAsync(); + } + public Task CountMediaItems(int id) { using TvContext context = _dbContextFactory.CreateDbContext(); @@ -133,14 +168,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await context.SaveChangesAsync(); } - public Task DisablePlexLibrarySync(IEnumerable libraryIds) => - _dbConnection.ExecuteAsync( - "UPDATE PlexMediaSourceLibraries SET ShouldSyncItems = 0 WHERE Id in @ids", + public async Task DisablePlexLibrarySync(List libraryIds) + { + await _dbConnection.ExecuteAsync( + "UPDATE PlexLibrary 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 }); + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN PlexMovie 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 }); + } + public Task EnablePlexLibrarySync(IEnumerable libraryIds) => _dbConnection.ExecuteAsync( - "UPDATE PlexMediaSourceLibraries SET ShouldSyncItems = 1 WHERE Id in @ids", + "UPDATE PlexLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids", new { ids = libraryIds }); } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index eef1d68c4..f6b007c8a 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -58,15 +58,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public async Task> GetOrAdd(PlexLibrary library, PlexMovie item) { - Option maybeExisting = await _dbContext.PlexMovieMediaItems + await using TvContext context = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await context.PlexMovies + .AsNoTracking() .Include(i => i.MovieMetadata) - .Include(i => i.Part) + .Include(i => i.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) .OrderBy(i => i.Key) .SingleOrDefaultAsync(i => i.Key == item.Key); return await maybeExisting.Match( plexMovie => Right(plexMovie).AsTask(), - async () => await AddPlexMovie(library, item)); + async () => await AddPlexMovie(context, library, item)); } public async Task Update(Movie movie) @@ -119,17 +122,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } } - private async Task> AddPlexMovie( - PlexLibrary library, - PlexMovie item) + private async Task> AddPlexMovie(TvContext context, PlexLibrary library, PlexMovie item) { try { item.LibraryPathId = library.Paths.Head().Id; - await _dbContext.PlexMovieMediaItems.AddAsync(item); - await _dbContext.SaveChangesAsync(); - await _dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + await context.PlexMovies.AddAsync(item); + await context.SaveChangesAsync(); + await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); return item; } catch (Exception ex) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs new file mode 100644 index 000000000..7f2a9b169 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class SearchRepository : ISearchRepository + { + private readonly IDbConnection _dbConnection; + private readonly IDbContextFactory _dbContextFactory; + + public SearchRepository(IDbContextFactory dbContextFactory, IDbConnection dbConnection) + { + _dbContextFactory = dbContextFactory; + _dbConnection = dbConnection; + } + + public async Task> SearchMediaItems(string query) + { + List ids = await _dbConnection.QueryAsync( + @"SELECT M.Id FROM Movie M + INNER JOIN MovieMetadata MM on M.Id = MM.MovieId + WHERE MM.Title LIKE @Query + UNION + SELECT S.Id FROM Show S + INNER JOIN ShowMetadata SM on S.Id = SM.ShowId + WHERE SM.Title LIKE @Query", + new { Query = $"%{query}%" }) + .Map(results => results.ToList()); + + await using TvContext context = _dbContextFactory.CreateDbContext(); + return await context.MediaItems + .Filter(m => ids.Contains(m.Id)) + .Include(m => (m as Movie).MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(m => (m as Show).ShowMetadata) + .ThenInclude(mm => mm.Artwork) + .OfType() + .ToListAsync(); + } + } +} diff --git a/ErsatzTV.Infrastructure/Data/TvContext.cs b/ErsatzTV.Infrastructure/Data/TvContext.cs index f809f0f85..ec683cc67 100644 --- a/ErsatzTV.Infrastructure/Data/TvContext.cs +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data public DbSet LocalLibraries { get; set; } public DbSet LibraryPaths { get; set; } public DbSet PlexLibraries { get; set; } + public DbSet PlexPathReplacements { get; set; } public DbSet MediaItems { get; set; } public DbSet MediaVersions { get; set; } public DbSet MediaFiles { get; set; } @@ -31,7 +32,7 @@ namespace ErsatzTV.Infrastructure.Data public DbSet Seasons { get; set; } public DbSet Episodes { get; set; } public DbSet EpisodeMetadata { get; set; } - public DbSet PlexMovieMediaItems { get; set; } + public DbSet PlexMovies { get; set; } public DbSet Collections { get; set; } public DbSet ProgramSchedules { get; set; } public DbSet Playouts { get; set; } diff --git a/ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.Designer.cs new file mode 100644 index 000000000..db5e39c30 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.Designer.cs @@ -0,0 +1,1456 @@ +// +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("20210228210815_Add_PlexMediaFile")] + partial class Add_PlexMediaFile + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("Path") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + 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("INTEGER"); + + 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.HasKey("Id"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .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.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("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .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.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.LibraryPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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.MediaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + 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("Name") + .HasColumnType("TEXT"); + + b.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.Property("VideoScanKind") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.ToTable("MediaVersion"); + }); + + 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.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("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.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("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.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.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.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.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("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.PlexMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexMovie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + 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.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.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.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.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.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); + }); + + 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.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("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.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.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.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.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.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.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.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("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Navigation("Paths"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + 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"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Navigation("Artwork"); + }); + + 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("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Navigation("Artwork"); + }); + + 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.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.PlexMediaSource", b => + { + b.Navigation("Connections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.cs b/ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.cs new file mode 100644 index 000000000..733829a9d --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Add_PlexMediaFile : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + "FK_PlexMovie_PlexMediaItemPart_PartId", + "PlexMovie"); + + migrationBuilder.DropTable( + "PlexMediaItemPart"); + + migrationBuilder.DropIndex( + "IX_PlexMovie_PartId", + "PlexMovie"); + + migrationBuilder.DropColumn( + "PartId", + "PlexMovie"); + + migrationBuilder.CreateTable( + "PlexMediaFile", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlexId = table.Column("INTEGER", nullable: false), + Key = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlexMediaFile", x => x.Id); + table.ForeignKey( + "FK_PlexMediaFile_MediaFile_Id", + x => x.Id, + "MediaFile", + "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "PlexMediaFile"); + + migrationBuilder.AddColumn( + "PartId", + "PlexMovie", + "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + "PlexMediaItemPart", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Duration = table.Column("INTEGER", nullable: false), + File = table.Column("TEXT", nullable: true), + Key = table.Column("TEXT", nullable: true), + PlexId = table.Column("INTEGER", nullable: false), + Size = table.Column("INTEGER", nullable: false) + }, + constraints: table => { table.PrimaryKey("PK_PlexMediaItemPart", x => x.Id); }); + + migrationBuilder.CreateIndex( + "IX_PlexMovie_PartId", + "PlexMovie", + "PartId"); + + migrationBuilder.AddForeignKey( + "FK_PlexMovie_PlexMediaItemPart_PartId", + "PlexMovie", + "PartId", + "PlexMediaItemPart", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs new file mode 100644 index 000000000..58d9cba48 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs @@ -0,0 +1,1491 @@ +// +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("20210301001541_Add_PlexPathReplacement")] + partial class Add_PlexPathReplacement + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("Path") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + 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("INTEGER"); + + 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.HasKey("Id"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .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.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("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .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.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.LibraryPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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.MediaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + 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("Name") + .HasColumnType("TEXT"); + + b.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.Property("VideoScanKind") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.ToTable("MediaVersion"); + }); + + 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.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("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("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("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.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.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.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.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("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.PlexMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexMovie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + 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.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.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.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.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.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); + }); + + 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.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("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.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.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.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.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.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.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("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Navigation("Paths"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + 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"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Navigation("Artwork"); + }); + + 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("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Navigation("Artwork"); + }); + + 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.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.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs new file mode 100644 index 000000000..646da4a9b --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Add_PlexPathReplacement : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "PlexPathReplacement", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlexPath = table.Column("TEXT", nullable: true), + LocalPath = table.Column("TEXT", nullable: true), + PlexMediaSourceId = table.Column("INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlexPathReplacement", x => x.Id); + table.ForeignKey( + "FK_PlexPathReplacement_PlexMediaSource_PlexMediaSourceId", + x => x.PlexMediaSourceId, + "PlexMediaSource", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + "IX_PlexPathReplacement_PlexMediaSourceId", + "PlexPathReplacement", + "PlexMediaSourceId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) => + migrationBuilder.DropTable( + "PlexPathReplacement"); + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 3bdb826ad..a1972ca0f 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -1,14 +1,14 @@ // + using System; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ErsatzTV.Infrastructure.Migrations { [DbContext(typeof(TvContext))] - partial class TvContextModelSnapshot : ModelSnapshot + internal class TvContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { @@ -16,7 +16,9 @@ namespace ErsatzTV.Infrastructure.Migrations modelBuilder .HasAnnotation("ProductVersion", "5.0.3"); - modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Artwork", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -64,7 +66,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Artwork"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Channel", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -95,7 +99,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Channel"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Collection", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -109,7 +115,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Collection"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.CollectionItem", + b => { b.Property("CollectionId") .HasColumnType("INTEGER"); @@ -124,7 +132,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("CollectionItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ConfigElement", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -144,7 +154,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ConfigElement"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EpisodeMetadata", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -193,7 +205,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("EpisodeMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.FFmpegProfile", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -257,7 +271,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("FFmpegProfile"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Library", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -282,7 +298,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Library"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LibraryPath", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -301,7 +319,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("LibraryPath"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MediaFile", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -323,7 +343,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("MediaFile"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MediaItem", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -339,7 +361,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("MediaItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MediaSource", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -350,7 +374,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("MediaSource"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MediaVersion", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -404,7 +430,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("MediaVersion"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MovieMetadata", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -453,7 +481,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("MovieMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Playout", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -477,7 +507,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Playout"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlayoutItem", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -504,7 +536,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlayoutItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -538,7 +572,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlayoutProgramScheduleAnchor"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexConnection", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -560,33 +596,33 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexConnection"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaItemPart", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexPathReplacement", + b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Duration") - .HasColumnType("INTEGER"); - - b.Property("File") + b.Property("LocalPath") .HasColumnType("TEXT"); - b.Property("Key") - .HasColumnType("TEXT"); - - b.Property("PlexId") + b.Property("PlexMediaSourceId") .HasColumnType("INTEGER"); - b.Property("Size") - .HasColumnType("INTEGER"); + b.Property("PlexPath") + .HasColumnType("TEXT"); b.HasKey("Id"); - b.ToTable("PlexMediaItemPart"); + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexPathReplacement"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramSchedule", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -606,7 +642,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ProgramSchedule"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItem", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -641,7 +679,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ProgramScheduleItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Resolution", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -661,7 +701,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Resolution"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.SeasonMetadata", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -704,7 +746,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("SeasonMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ShowMetadata", + b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -753,14 +797,18 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ShowMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LocalLibrary", + b => { b.HasBaseType("ErsatzTV.Core.Domain.Library"); b.ToTable("LocalLibrary"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexLibrary", + b => { b.HasBaseType("ErsatzTV.Core.Domain.Library"); @@ -773,7 +821,24 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexLibrary"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + 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.Episode", + b => { b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); @@ -788,14 +853,18 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Episode"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Movie", + b => { b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); b.ToTable("Movie"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Season", + b => { b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); @@ -810,21 +879,27 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("Season"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Show", + b => { b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); b.ToTable("Show"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LocalMediaSource", + b => { b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); b.ToTable("LocalMediaSource"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexMediaSource", + b => { b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); @@ -840,7 +915,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexMediaSource"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemDuration", + b => { b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); @@ -853,14 +930,18 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ProgramScheduleDurationItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemFlood", + b => { b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); b.ToTable("ProgramScheduleFloodItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", + b => { b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); @@ -870,29 +951,30 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ProgramScheduleMultipleItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemOne", + b => { b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); b.ToTable("ProgramScheduleOneItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexMovie", + b => { b.HasBaseType("ErsatzTV.Core.Domain.Movie"); b.Property("Key") .HasColumnType("TEXT"); - b.Property("PartId") - .HasColumnType("INTEGER"); - - b.HasIndex("PartId"); - b.ToTable("PlexMovie"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Artwork", + b => { b.HasOne("ErsatzTV.Core.Domain.Channel", null) .WithMany("Artwork") @@ -920,7 +1002,9 @@ namespace ErsatzTV.Infrastructure.Migrations .OnDelete(DeleteBehavior.Cascade); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Channel", + b => { b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") .WithMany() @@ -931,7 +1015,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("FFmpegProfile"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.CollectionItem", + b => { b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") .WithMany("CollectionItems") @@ -950,7 +1036,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("MediaItem"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.EpisodeMetadata", + b => { b.HasOne("ErsatzTV.Core.Domain.Episode", "Episode") .WithMany("EpisodeMetadata") @@ -961,7 +1049,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Episode"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.FFmpegProfile", + b => { b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") .WithMany() @@ -972,7 +1062,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Resolution"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Library", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaSource", "MediaSource") .WithMany("Libraries") @@ -983,7 +1075,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("MediaSource"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LibraryPath", + b => { b.HasOne("ErsatzTV.Core.Domain.Library", "Library") .WithMany("Paths") @@ -994,7 +1088,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Library"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MediaFile", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") .WithMany("MediaFiles") @@ -1005,7 +1101,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("MediaVersion"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MediaItem", + b => { b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath") .WithMany("MediaItems") @@ -1016,7 +1114,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("LibraryPath"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MediaVersion", + b => { b.HasOne("ErsatzTV.Core.Domain.Episode", null) .WithMany("MediaVersions") @@ -1029,7 +1129,9 @@ namespace ErsatzTV.Infrastructure.Migrations .OnDelete(DeleteBehavior.Cascade); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.MovieMetadata", + b => { b.HasOne("ErsatzTV.Core.Domain.Movie", "Movie") .WithMany("MovieMetadata") @@ -1040,7 +1142,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Movie"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Playout", + b => { b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") .WithMany("Playouts") @@ -1054,7 +1158,10 @@ namespace ErsatzTV.Infrastructure.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + b.OwnsOne( + "ErsatzTV.Core.Domain.PlayoutAnchor", + "Anchor", + b1 => { b1.Property("PlayoutId") .HasColumnType("INTEGER"); @@ -1090,7 +1197,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("ProgramSchedule"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlayoutItem", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") .WithMany() @@ -1109,7 +1218,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Playout"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", + b => { b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") .WithMany() @@ -1133,7 +1244,10 @@ namespace ErsatzTV.Infrastructure.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 => + b.OwnsOne( + "ErsatzTV.Core.Domain.CollectionEnumeratorState", + "EnumeratorState", + b1 => { b1.Property("PlayoutProgramScheduleAnchorId") .HasColumnType("INTEGER"); @@ -1163,7 +1277,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("ProgramSchedule"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexConnection", + b => { b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource") .WithMany("Connections") @@ -1174,7 +1290,22 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("PlexMediaSource"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + 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() @@ -1199,7 +1330,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("ProgramSchedule"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.SeasonMetadata", + b => { b.HasOne("ErsatzTV.Core.Domain.Season", "Season") .WithMany("SeasonMetadata") @@ -1210,7 +1343,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Season"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ShowMetadata", + b => { b.HasOne("ErsatzTV.Core.Domain.Show", "Show") .WithMany("ShowMetadata") @@ -1221,7 +1356,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Show"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LocalLibrary", + b => { b.HasOne("ErsatzTV.Core.Domain.Library", null) .WithOne() @@ -1230,7 +1367,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexLibrary", + b => { b.HasOne("ErsatzTV.Core.Domain.Library", null) .WithOne() @@ -1239,7 +1378,20 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + 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.Episode", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) .WithOne() @@ -1256,7 +1408,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Season"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Movie", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) .WithOne() @@ -1265,7 +1419,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Season", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) .WithOne() @@ -1282,7 +1438,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Navigation("Show"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Show", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) .WithOne() @@ -1291,7 +1449,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.LocalMediaSource", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) .WithOne() @@ -1300,7 +1460,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexMediaSource", + b => { b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) .WithOne() @@ -1309,7 +1471,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemDuration", + b => { b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) .WithOne() @@ -1318,7 +1482,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemFlood", + b => { b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) .WithOne() @@ -1327,7 +1493,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", + b => { b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) .WithOne() @@ -1336,7 +1504,9 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramScheduleItemOne", + b => { b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) .WithOne() @@ -1345,123 +1515,107 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + 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(); - - b.HasOne("ErsatzTV.Core.Domain.PlexMediaItemPart", "Part") - .WithMany() - .HasForeignKey("PartId"); - - b.Navigation("Part"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + 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.Collection", b => { b.Navigation("CollectionItems"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => - { - b.Navigation("Artwork"); - }); + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => { b.Navigation("Artwork"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => - { - b.Navigation("Paths"); - }); + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => { b.Navigation("Paths"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => - { - b.Navigation("MediaItems"); - }); + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => { b.Navigation("MediaItems"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => - { - b.Navigation("CollectionItems"); - }); + 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.MediaSource", b => { b.Navigation("Libraries"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => - { - b.Navigation("MediaFiles"); - }); + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => { b.Navigation("MediaFiles"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => - { - b.Navigation("Artwork"); - }); + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => { b.Navigation("Artwork"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Playout", + b => { b.Navigation("Items"); b.Navigation("ProgramScheduleAnchors"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.ProgramSchedule", + b => { b.Navigation("Items"); b.Navigation("Playouts"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => - { - b.Navigation("Artwork"); - }); + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => { b.Navigation("Artwork"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => - { - b.Navigation("Artwork"); - }); + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => { b.Navigation("Artwork"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Episode", + b => { b.Navigation("EpisodeMetadata"); b.Navigation("MediaVersions"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Movie", + b => { b.Navigation("MediaVersions"); b.Navigation("MovieMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Season", + b => { b.Navigation("Episodes"); b.Navigation("SeasonMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.Show", + b => { b.Navigation("Seasons"); b.Navigation("ShowMetadata"); }); - modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexMediaSource", + b => { b.Navigation("Connections"); + + b.Navigation("PathReplacements"); }); #pragma warning restore 612, 618 } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs index ada996f88..45602b36f 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs @@ -6,5 +6,6 @@ public string Title { get; set; } public string Type { get; set; } public int Hidden { get; set; } + public string Uuid { get; set; } } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs index 758de563a..c8a09a21d 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs @@ -6,6 +6,5 @@ public string Key { get; set; } public int Duration { get; set; } public string File { get; set; } - public int Size { get; set; } } } diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index 1be8594ca..0447915bb 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Plex; using ErsatzTV.Infrastructure.Plex.Models; @@ -15,6 +16,13 @@ namespace ErsatzTV.Infrastructure.Plex { public class PlexServerApiClient : IPlexServerApiClient { + private readonly IFallbackMetadataProvider _fallbackMetadataProvider; + + public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider) + { + _fallbackMetadataProvider = fallbackMetadataProvider; + } + public async Task>> GetLibraries( PlexConnection connection, PlexServerAuthToken token) @@ -46,7 +54,7 @@ namespace ErsatzTV.Infrastructure.Plex IPlexServerApi service = RestService.For(connection.Uri); return await service.GetLibrarySectionContents(library.Key, token.AuthToken) .Map(r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)) - .Map(list => list.Map(ProjectToMovie).ToList()); + .Map(list => list.Map(metadata => ProjectToMovie(metadata, library.MediaSourceId)).ToList()); } catch (Exception ex) { @@ -62,108 +70,87 @@ namespace ErsatzTV.Infrastructure.Plex Key = response.Key, Name = response.Title, MediaKind = LibraryMediaKind.Shows, - ShouldSyncItems = false + ShouldSyncItems = false, + Paths = new List { new() { Path = $"plex://{response.Uuid}" } } }, "movie" => new PlexLibrary { Key = response.Key, Name = response.Title, MediaKind = LibraryMediaKind.Movies, - ShouldSyncItems = false + ShouldSyncItems = false, + Paths = new List { new() { Path = $"plex://{response.Uuid}" } } }, // TODO: "artist" for music libraries _ => None }; - private static PlexPartEntry Project(PlexPartResponse response) => - new() - { - Id = response.Id, - Key = response.Key, - Duration = response.Duration, - File = response.File, - Size = response.Size - }; - - private static PlexMediaEntry Project(PlexMediaResponse response) => - new() - { - Id = response.Id, - Duration = response.Duration, - Bitrate = response.Bitrate, - Width = response.Width, - Height = response.Height, - AspectRatio = response.AspectRatio, - AudioChannels = response.AudioChannels, - AudioCodec = response.AudioCodec, - VideoCodec = response.VideoCodec, - Container = response.Container, - VideoFrameRate = response.VideoFrameRate, - Part = response.Part.Map(Project).ToList() - }; - - private static PlexMetadataEntry Project(PlexMetadataResponse response) => - new() - { - Key = response.Key, - Title = response.Title, - Summary = response.Summary, - Year = response.Year, - Tagline = response.Tagline, - Thumb = response.Thumb, - Art = response.Art, - AddedAt = response.AddedAt, - UpdatedAt = response.UpdatedAt, - Media = response.Media.Map(Project).ToList() - }; - - private static PlexMovie ProjectToMovie(PlexMetadataResponse response) + private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId) { PlexMediaResponse media = response.Media.Head(); PlexPartResponse part = media.Part.Head(); + DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new MovieMetadata { Title = response.Title, + SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), Plot = response.Summary, - ReleaseDate = new DateTime(response.Year, 1, 1), // TODO: actual release date? + ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt), Year = response.Year, Tagline = response.Tagline, - DateAdded = DateTime.UtcNow, // TODO: actual date added? + DateAdded = dateAdded, DateUpdated = lastWriteTime }; - // TODO: artwork + if (!string.IsNullOrWhiteSpace(response.Thumb)) + { + var path = $"plex/{mediaSourceId}{response.Thumb}"; + var artwork = new Artwork + { + ArtworkKind = ArtworkKind.Poster, + Path = path, + DateAdded = dateAdded, + DateUpdated = lastWriteTime + }; + + metadata.Artwork ??= new List(); + metadata.Artwork.Add(artwork); + } + + var version = new MediaVersion + { + Name = "Main", + Duration = TimeSpan.FromMilliseconds(media.Duration), + Width = media.Width, + Height = media.Height, + AudioCodec = media.AudioCodec, + VideoCodec = media.VideoCodec, + SampleAspectRatio = ConvertToSAR(media.AspectRatio), + MediaFiles = new List + { + new PlexMediaFile + { + PlexId = part.Id, + Key = part.Key, + Path = part.File + } + } + }; var movie = new PlexMovie { Key = response.Key, - // DateUpdated = lastWriteTime, MovieMetadata = new List { metadata }, - // TODO: versions - // Statistics = new MediaItemStatistics - // { - // Duration = TimeSpan.FromMilliseconds(media.Duration), - // Width = media.Width, - // Height = media.Height, - // // TODO: aspect ratio - // AudioCodec = media.AudioCodec, - // VideoCodec = media.VideoCodec, - // LastWriteTime = lastWriteTime - // }, - // TODO: multi-part movies? - Part = new PlexMediaItemPart - { - PlexId = part.Id, - Key = part.Key, - File = part.File, - Duration = part.Duration, - Size = part.Size - } + MediaVersions = new List { version } }; return movie; } + + private static string ConvertToSAR(double aspectRatio) => "1:1"; + // TODO: fix this with more detailed stats pull from plex for each item + // Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100"; } } diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index 66014ecd4..5170da035 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -1,6 +1,11 @@ -using System.Threading.Tasks; +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; using ErsatzTV.Application.Images; using ErsatzTV.Application.Images.Queries; +using ErsatzTV.Application.Plex; +using ErsatzTV.Application.Plex.Queries; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using LanguageExt; @@ -13,9 +18,14 @@ namespace ErsatzTV.Controllers [ApiExplorerSettings(IgnoreApi = true)] public class PostersController : ControllerBase { + private readonly IHttpClientFactory _httpClientFactory; private readonly IMediator _mediator; - public PostersController(IMediator mediator) => _mediator = mediator; + public PostersController(IMediator mediator, IHttpClientFactory httpClientFactory) + { + _mediator = mediator; + _httpClientFactory = httpClientFactory; + } [HttpGet("/artwork/posters/{fileName}")] public async Task GetPoster(string fileName) @@ -27,6 +37,32 @@ namespace ErsatzTV.Controllers Right: r => new FileContentResult(r.Contents, r.MimeType)); } + [HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")] + public async Task GetPlexPoster(int plexMediaSourceId, string path) + { + Either connectionParameters = + await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId)); + + return await connectionParameters.Match>( + Left: _ => new NotFoundResult().AsTask(), + Right: async r => + { + HttpClient client = _httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken); + + var transcodePath = $"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0"; + var fullPath = new Uri(r.Uri, transcodePath); + HttpResponseMessage response = await client.GetAsync( + fullPath, + HttpCompletionOption.ResponseHeadersRead); + Stream stream = await response.Content.ReadAsStreamAsync(); + + return new FileStreamResult( + stream, + response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); + }); + } + [HttpGet("/artwork/thumbnails/{fileName}")] public async Task GetThumbnail(string fileName) { diff --git a/ErsatzTV/Pages/Libraries.razor b/ErsatzTV/Pages/Libraries.razor index 3efdd98fa..6cfd2e1aa 100644 --- a/ErsatzTV/Pages/Libraries.razor +++ b/ErsatzTV/Pages/Libraries.razor @@ -2,6 +2,7 @@ @using ErsatzTV.Application.Libraries @using ErsatzTV.Application.Libraries.Queries @using ErsatzTV.Application.MediaSources.Commands +@using ErsatzTV.Application.Plex.Commands @implements IDisposable @inject IMediator Mediator @inject IEntityLocker Locker @@ -25,7 +26,7 @@ - Local + @context.LibraryKind @context.Name @context.MediaKind @@ -61,7 +62,7 @@ @code { - private IList _libraries; + private IList _libraries; protected override void OnInitialized() => Locker.OnLibraryChanged += LockChanged; @@ -69,13 +70,22 @@ protected override async Task OnParametersSetAsync() => await LoadLibraries(); private async Task LoadLibraries() => - _libraries = await Mediator.Send(new GetLocalLibraries()); + _libraries = await Mediator.Send(new GetAllLibraries()); - private async Task ScanLibrary(LocalLibraryViewModel library) + private async Task ScanLibrary(LibraryViewModel library) { if (Locker.LockLibrary(library.Id)) { - await Channel.WriteAsync(new ForceScanLocalLibrary(library.Id)); + switch (library) + { + case LocalLibraryViewModel: + await Channel.WriteAsync(new ForceScanLocalLibrary(library.Id)); + break; + case PlexLibraryViewModel: + await Channel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id)); + break; + } + StateHasChanged(); } } diff --git a/ErsatzTV/Pages/MovieList.razor b/ErsatzTV/Pages/MovieList.razor index 74d134695..e312f82d5 100644 --- a/ErsatzTV/Pages/MovieList.razor +++ b/ErsatzTV/Pages/MovieList.razor @@ -2,7 +2,13 @@ @page "/media/movies/page/{PageNumber:int}" @using ErsatzTV.Application.MediaCards @using ErsatzTV.Application.MediaCards.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@using Unit = LanguageExt.Unit +@inject ILogger Logger +@inject ISnackbar Snackbar @inject IMediator Mediator +@inject IDialogService Dialog @inject NavigationManager NavigationManager @@ -24,7 +30,9 @@ @foreach (MovieCardViewModel card in _data.Cards.Where(d => !string.IsNullOrWhiteSpace(d.Title))) { - + } @@ -53,4 +61,28 @@ private void NextPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber + 1}"); + private async Task AddToCollection(MediaCardViewModel card) + { + if (card is MovieCardViewModel movie) + { + var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + var request = new AddMovieToCollection(collection.Id, movie.MovieId); + Either addResult = await Mediator.Send(request); + addResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}"); + Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value); + }, + Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success)); + } + } + } + } \ No newline at end of file diff --git a/ErsatzTV/Pages/Playouts.razor b/ErsatzTV/Pages/Playouts.razor index b5af3bcff..8f2320177 100644 --- a/ErsatzTV/Pages/Playouts.razor +++ b/ErsatzTV/Pages/Playouts.razor @@ -14,7 +14,7 @@ - + Id @@ -29,7 +29,18 @@ @context.ProgramSchedule.Name @* @context.ProgramSchedulePlayoutType *@ - +
+ + + + + + + + +
@@ -63,12 +74,16 @@ @code { private List _playouts; private List _selectedPlayoutItems; + private int? _selectedPlayoutId; protected override Task OnParametersSetAsync() => LoadAllPlayouts(); - private async Task PlayoutSelected(PlayoutViewModel playout) => + private async Task PlayoutSelected(PlayoutViewModel playout) + { + _selectedPlayoutId = playout.Id; _selectedPlayoutItems = await Mediator.Send(new GetPlayoutItemsById(playout.Id)); + } private async Task DeletePlayout(PlayoutViewModel playout) { @@ -84,6 +99,16 @@ } } + private async Task RebuildPlayout(PlayoutViewModel playout) + { + await Mediator.Send(new BuildPlayout(playout.Id, true)); + await LoadAllPlayouts(); + if (_selectedPlayoutId == playout.Id) + { + await PlayoutSelected(playout); + } + } + private async Task LoadAllPlayouts() => _playouts = await Mediator.Send(new GetAllPlayouts()); diff --git a/ErsatzTV/Pages/PlexLibrariesEditor.razor b/ErsatzTV/Pages/PlexLibrariesEditor.razor index fd20f350d..e2395809d 100644 --- a/ErsatzTV/Pages/PlexLibrariesEditor.razor +++ b/ErsatzTV/Pages/PlexLibrariesEditor.razor @@ -6,9 +6,11 @@ @inject NavigationManager NavigationManager @inject ILogger Logger @inject ISnackbar Snackbar +@inject ChannelWriter Channel +@inject IEntityLocker Locker - + @_source.Name Libraries @@ -86,13 +88,25 @@ Seq errorMessages = await Mediator.Send(request).Map(e => e.LeftToSeq()); - errorMessages.HeadOrNone().Match( + 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; }, - () => NavigationManager.NavigateTo("/media/sources/1")); + 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"); + }); } } \ No newline at end of file diff --git a/ErsatzTV/Shared/PlexMediaSources.razor b/ErsatzTV/Pages/PlexMediaSources.razor similarity index 56% rename from ErsatzTV/Shared/PlexMediaSources.razor rename to ErsatzTV/Pages/PlexMediaSources.razor index cff646462..2c79cba0b 100644 --- a/ErsatzTV/Shared/PlexMediaSources.razor +++ b/ErsatzTV/Pages/PlexMediaSources.razor @@ -1,4 +1,5 @@ -@using ErsatzTV.Application.Plex +@page "/media/plex" +@using ErsatzTV.Application.Plex @using ErsatzTV.Application.Plex.Commands @using ErsatzTV.Application.Plex.Queries @inject IDialogService Dialog @@ -7,38 +8,44 @@ @inject ILogger Logger @inject IJSRuntime JsRuntime - - - Plex Media Sources - - - - - - - - Name - Address - - - - @context.Name - @context.Address - - - - Edit Libraries - - @* *@ - @* Edit Path Replacements *@ - @* *@ - - - - - - Add Plex Media Source - + + + + Plex Media Sources + + + + + + + + Name + Address + + + + @context.Name + @context.Address + +
+ + + + + + + + +
+
+
+
+ @* *@ + @* Add Plex Media Source *@ + @* *@ +
@code { private List _mediaSources; diff --git a/ErsatzTV/Pages/PlexPathReplacementsEditor.razor b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor new file mode 100644 index 000000000..325471ae6 --- /dev/null +++ b/ErsatzTV/Pages/PlexPathReplacementsEditor.razor @@ -0,0 +1,135 @@ +@page "/media/sources/plex/{Id:int}/paths" +@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) + { +
+ + + + + + + + + +
+ } +
+ +@code { + + [Parameter] + public int Id { get; set; } + + private PlexMediaSourceViewModel _source; + private List _pathReplacements; + + 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 PlexPathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) => + new() { Id = item.Id, PlexPath = item.PlexPath, LocalPath = item.LocalPath }; + + private void AddPathReplacement() + { + 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)) + .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")); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/Schedules.razor b/ErsatzTV/Pages/Schedules.razor index 157b55e81..3c767ee88 100644 --- a/ErsatzTV/Pages/Schedules.razor +++ b/ErsatzTV/Pages/Schedules.razor @@ -14,7 +14,7 @@ - + Id @@ -27,17 +27,23 @@ @context.Name @context.MediaCollectionPlaybackOrder - - - Edit Properties - - - Edit Schedule Items - - - Delete - - +
+ + + + + + + + + + + + +
diff --git a/ErsatzTV/Pages/Search.razor b/ErsatzTV/Pages/Search.razor new file mode 100644 index 000000000..e18b378fe --- /dev/null +++ b/ErsatzTV/Pages/Search.razor @@ -0,0 +1,110 @@ +@page "/search" +@using ErsatzTV.Application.MediaCards +@using ErsatzTV.Application.MediaCards.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@using Microsoft.AspNetCore.WebUtilities +@using Microsoft.Extensions.Primitives +@using Unit = LanguageExt.Unit +@inject NavigationManager NavigationManager +@inject IMediator Mediator +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IDialogService Dialog + + +
+ Search Results: "@_query" +
+ + @if (_data?.MovieCards.Any() == true) + { + Movies + + + @foreach (MovieCardViewModel card in _data.MovieCards) + { + + } + + } + + @if (_data?.ShowCards.Any() == true) + { + Television Shows + + + @foreach (TelevisionShowCardViewModel card in _data.ShowCards) + { + + } + + } +
+ +@code { + private string _query; + private SearchCardResultsViewModel _data; + + protected override async Task OnInitializedAsync() + { + string query = new Uri(NavigationManager.Uri).Query; + + if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value)) + { + _query = value; + Either maybeResults = await Mediator.Send(new GetSearchCards(_query)); + maybeResults.IfRight(results => _data = results); + } + } + + private async Task AddToCollection(MediaCardViewModel card) + { + if (card is MovieCardViewModel movie) + { + var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + var request = new AddMovieToCollection(collection.Id, movie.MovieId); + Either addResult = await Mediator.Send(request); + addResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}"); + Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value); + }, + Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success)); + } + } + + if (card is TelevisionShowCardViewModel show) + { + var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + var request = new AddShowToCollection(collection.Id, show.TelevisionShowId); + Either addResult = await Mediator.Send(request); + addResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error adding show to collection: {error.Value}"); + Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value); + }, + Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success)); + } + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/TelevisionEpisodeList.razor b/ErsatzTV/Pages/TelevisionEpisodeList.razor index 6ef6cab04..430d83b55 100644 --- a/ErsatzTV/Pages/TelevisionEpisodeList.razor +++ b/ErsatzTV/Pages/TelevisionEpisodeList.razor @@ -7,7 +7,10 @@ @using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.ProgramSchedules @using ErsatzTV.Application.ProgramSchedules.Commands +@using Unit=LanguageExt.Unit; @inject IMediator Mediator +@inject ILogger Logger +@inject ISnackbar Snackbar @inject IDialogService Dialog @inject NavigationManager NavigationManager @@ -53,7 +56,7 @@ @@ -119,5 +122,29 @@ NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); } } + + private async Task AddEpisodeToCollection(MediaCardViewModel card) + { + if (card is TelevisionEpisodeCardViewModel episode) + { + var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId); + Either addResult = await Mediator.Send(request); + addResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}"); + Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value); + }, + Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success)); + } + } + } } \ No newline at end of file diff --git a/ErsatzTV/Pages/TelevisionSeasonList.razor b/ErsatzTV/Pages/TelevisionSeasonList.razor index 4701d4551..64dfd2569 100644 --- a/ErsatzTV/Pages/TelevisionSeasonList.razor +++ b/ErsatzTV/Pages/TelevisionSeasonList.razor @@ -7,7 +7,10 @@ @using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.ProgramSchedules @using ErsatzTV.Application.ProgramSchedules.Commands +@using Unit=LanguageExt.Unit; @inject IMediator Mediator +@inject ILogger Logger +@inject ISnackbar Snackbar @inject IDialogService Dialog @inject NavigationManager NavigationManager @@ -52,7 +55,7 @@ { + AddToCollectionClicked="@AddSeasonToCollection"/> }
@@ -113,5 +116,29 @@ NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); } } + + private async Task AddSeasonToCollection(MediaCardViewModel card) + { + if (card is TelevisionSeasonCardViewModel season) + { + var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId); + Either addResult = await Mediator.Send(request); + addResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error adding season to collection: {error.Value}"); + Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value); + }, + Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success)); + } + } + } } \ No newline at end of file diff --git a/ErsatzTV/Pages/TelevisionShowList.razor b/ErsatzTV/Pages/TelevisionShowList.razor index 78424d02e..2924ce570 100644 --- a/ErsatzTV/Pages/TelevisionShowList.razor +++ b/ErsatzTV/Pages/TelevisionShowList.razor @@ -1,7 +1,13 @@ @page "/media/tv/shows" @using ErsatzTV.Application.MediaCards @using ErsatzTV.Application.MediaCards.Queries +@using ErsatzTV.Application.MediaCollections +@using ErsatzTV.Application.MediaCollections.Commands +@using Unit = LanguageExt.Unit +@inject ILogger Logger +@inject ISnackbar Snackbar @inject IMediator Mediator +@inject IDialogService Dialog @@ -22,7 +28,9 @@ @foreach (TelevisionShowCardViewModel card in _data.Cards) { - + } @@ -49,4 +57,28 @@ await RefreshData(); } + private async Task AddToCollection(MediaCardViewModel card) + { + if (card is TelevisionShowCardViewModel show) + { + var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = Dialog.Show("Add To Collection", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) + { + var request = new AddShowToCollection(collection.Id, show.TelevisionShowId); + Either addResult = await Mediator.Send(request); + addResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error adding show to collection: {error.Value}"); + Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value); + }, + Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success)); + } + } + } + } \ No newline at end of file diff --git a/ErsatzTV/Pages/_Host.cshtml b/ErsatzTV/Pages/_Host.cshtml index b1a8c6612..36635de67 100644 --- a/ErsatzTV/Pages/_Host.cshtml +++ b/ErsatzTV/Pages/_Host.cshtml @@ -17,6 +17,7 @@ + @await Html.PartialAsync("../Shared/_Favicons") diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index c0ea170af..1ade69772 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -1,5 +1,7 @@ @using System.Reflection +@using System.Web @inherits LayoutComponentBase +@inject NavigationManager NavigationManager @@ -7,8 +9,14 @@ - - ErsatzTV + + M3U XMLTV @@ -21,11 +29,17 @@ - + + + ErsatzTV + - Home + Home Channels FFmpeg + @* *@ + @* Plex *@ + @* *@ Libraries TV Shows @@ -52,10 +66,8 @@ @code { private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute()?.InformationalVersion ?? "unknown"; - private bool _drawerOpen = true; - - private void DrawerToggle() => _drawerOpen = !_drawerOpen; - + private MudTextField _textField; + private MudTheme _ersatzTvTheme { get @@ -74,4 +86,15 @@ } } + private void OnSearchKeyDown(KeyboardEventArgs args) + { + if (args.Code == "Enter") + { + string query = HttpUtility.UrlEncode(_textField.Value); + _textField.Reset(); + NavigationManager.NavigateTo($"/search?query={query}", true); + StateHasChanged(); + } + } + } \ No newline at end of file diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index f6b9a13b4..6f78a7e2a 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -17,6 +17,13 @@
+ @if (AddToCollectionClicked.HasDelegate) + { + + } @if (DeleteClicked.HasDelegate) { DeleteClicked { get; set; } + [Parameter] + public EventCallback AddToCollectionClicked { get; set; } + private string GetPlaceholder(string sortTitle) { if (Placeholder != null) diff --git a/ErsatzTV/Shared/_Favicons.cshtml b/ErsatzTV/Shared/_Favicons.cshtml new file mode 100644 index 000000000..a4e62c8bf --- /dev/null +++ b/ErsatzTV/Shared/_Favicons.cshtml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 923e03c44..325e12d86 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -178,7 +178,6 @@ namespace ErsatzTV services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // TODO: does this need to be singleton? - services.AddSingleton(); services.AddSingleton(); AddChannel(services); AddChannel(services); @@ -194,6 +193,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -206,6 +206,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // services.AddHostedService(); services.AddHostedService(); diff --git a/ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs b/ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs new file mode 100644 index 000000000..0b84aa0ea --- /dev/null +++ b/ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs @@ -0,0 +1,14 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class PlexPathReplacementEditViewModelValidator : AbstractValidator + { + public PlexPathReplacementEditViewModelValidator() + { + RuleFor(vm => vm.PlexPath).NotEmpty(); + RuleFor(vm => vm.LocalPath).NotEmpty(); + } + } +} diff --git a/ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs b/ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs new file mode 100644 index 000000000..66214781b --- /dev/null +++ b/ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.ViewModels +{ + public class PlexPathReplacementEditViewModel + { + public int Id { get; set; } + public string PlexPath { get; set; } + public string LocalPath { get; set; } + } +} diff --git a/ErsatzTV/wwwroot/android-chrome-192x192.png b/ErsatzTV/wwwroot/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..3a4eb75a40ab0c837ccf19286b009481fa1752cc GIT binary patch literal 3318 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 literal 0 HcmV?d00001 diff --git a/ErsatzTV/wwwroot/android-chrome-512x512.png b/ErsatzTV/wwwroot/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..cd49a8e44e35894a42003d4a75451b421b6e351f GIT binary patch literal 9604 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 aGX;pm%^tL3sQMU)fJ}|7&Q=+^-u^$Qs2=SA literal 0 HcmV?d00001 diff --git a/ErsatzTV/wwwroot/apple-touch-icon.png b/ErsatzTV/wwwroot/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5fa9ba1a1c00e15c33b70811f699ba4e228dfdba GIT binary patch literal 3108 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 SogDTSfVBnE{H3Y)oqq#^$CG0K literal 0 HcmV?d00001 diff --git a/ErsatzTV/wwwroot/browserconfig.xml b/ErsatzTV/wwwroot/browserconfig.xml new file mode 100644 index 000000000..b3930d0f0 --- /dev/null +++ b/ErsatzTV/wwwroot/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index 58ddf3845..9d0ae74bb 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -4,9 +4,13 @@ flex-wrap: wrap; } -.media-card-container { width: 152px; } +.media-card-container { + width: 152px; +} -.media-card-episode-container { width: 392px; } +.media-card-episode-container { + width: 392px; +} .media-card { display: flex; @@ -18,9 +22,12 @@ width: 152px; } -.media-card-episode { width: 392px; } +.media-card-episode { + width: 392px; +} -.media-card:hover { /*filter: brightness(75%);*/ } +.media-card:hover { /*filter: brightness(75%);*/ +} .media-card-title { overflow: hidden; @@ -29,7 +36,9 @@ width: 100%; } -.media-card-poster-placeholder { font-weight: bold; } +.media-card-poster-placeholder { + font-weight: bold; +} .media-card-menu { bottom: 0; @@ -38,7 +47,9 @@ right: 0; } -.media-card:hover .media-card-menu { display: block; } +.media-card:hover .media-card-menu { + display: block; +} .media-card-overlay { background: rgba(0, 0, 0, 0.4); @@ -52,4 +63,26 @@ transition: opacity 0.2s; } -.media-card-overlay:hover { opacity: 1; } \ No newline at end of file +.media-card-overlay:hover { + opacity: 1; +} + +.search-bar .mud-input { + height: 42px; +} + +.search-bar { + background-color: rgba(255, 255, 255, .15); + margin-bottom: 5px; + height: 42px; + border-radius: 4px; +} + +.search-bar div .mud-input-root { + color: #fafafa; +} + +.search-bar .mud-input.mud-input-outlined .mud-input-outlined-border { + border: none; + border-radius: 4px; +} diff --git a/ErsatzTV/wwwroot/favicon-16x16.png b/ErsatzTV/wwwroot/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..020d2dea023fac932376d42a4603ca2e2b704679 GIT binary patch literal 1032 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>KZr|Nq#YC3y@C43#B8 zexL|sU-Te|X679;(bHGbp*f8XcNzbkg; z#K-dob)4nne?>TaZM)aiUMHdWVwvKj>Gf}{cCslmGB3S(Za4R7H%BLJrKfvUqJI7@ z`u^qJr^Nyr{;X%0zy8d?Svtsu$=HHzy6nC}Q!>*kacf|$_NfJG z2(Jo>C<#g|S12gTPs_|nRVb+}NL9!zC`o2usF?Hk6AwpWn1;qF|I=qYp9V27D|725 za|bP0l+XkK*F%Rw literal 0 HcmV?d00001 diff --git a/ErsatzTV/wwwroot/favicon-32x32.png b/ErsatzTV/wwwroot/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..deb9644ec0d61b0ccb965f554cb5b3047a86b6ac GIT binary patch literal 1440 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+080~1$(Plzi}!GQw|a}Ov3 zsmcTHwg;LS4giJzhaJdbIG{K0fYQDLOh5$?1f^hn7!4AJOCbv)i^CN#9N5hOQjdvX z)_{ySj7d*fzb#{?yWwT0s*XgEW;N@w-qU|bj{_pSdx<9|K%d&6yd%T^I zMdaL{U!^vmet0WCtiHK!{TgFt^TakM1^#1g!M~mw{qkB-XvwjW>eWKL65 zQRX`GFIMl3R{hNNY*rtx>`ag0J@`apM^3JR(xZhIHEWCb4u79${x0Rs%zcm5^70Lt zZQB=2adpTKVKv%y9OP2QByV?@wF*gxWPu#^5>H=O_NUBDjKW%Xk_vVLrK>z$978Nl zCnrcqHn1~RNN&`OXl1DJ`NP!Ybm)u(my63m2cJ2vPDNX%Ff3!rjFS|YFg5w;LWg;R zIhLE77ESt;r70R}TFUFIn`>rfCd=y^o5EW4i!Eq%xU`gnafqeqk4XtZyR-TF6ylj=!ZYH*Evu>G|e$DQZ zzOg|ggGDnVD=zufwq;AF^2$ugt$C|(;o`}QSNB$x`mZ-IR*$|frSM{Xnset_&KP0! zwQ;!ym0ZTgx36SoUlZ|9cMd*qR>vUu>#M8Aja-}3*G3vw^99UH)RQ?g`}~PL2@e+? z^`6Egx8 zf};Gi%$!t(lFEWqh0KDIWCn(cIgdZ_a1@4VXq@stea7=?5CgL^w_Y;0u(GiCWD#az z1(ybs!zs+ln?n>%-?(z($eANDN7zp{cr5VJV|XPlSn|oqbSlsa22WQ%mvv4FO#n03 B9S{Hj literal 0 HcmV?d00001 diff --git a/ErsatzTV/wwwroot/favicon.ico b/ErsatzTV/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d8c0ce38698766ce5cee3b3ca779a613ef6e57ac GIT binary patch literal 15086 zcmeI2e~cYf701u+w(p@(UTvT>u(I3kS_mbjSR+l@+Fuk!|L`ZatctW01uQkF#1bo; zNNj@`37&}U`qlq{FJj4TSBoUSW{42Y83?vxFDw6cH8IY%$=FNbKl$Td+p*b zWM1-l=iWQ_&iURmcV_O~8IcBQl4;Wf+bVZ|NaUj;(%Ks5$BJyHY%JEcUpz`=6%|gV z4o&0&u=42F?o9rlM<9-Y)PGX4*Tw`e~jyGt3bD)&Gil&f%= zUq4MNQ#F-e0D3a~nf!@qJwI`H+O6N!^k1pfbtiopNPjarpAWQKU-rR5Uv`=vV*RQz z2A+gOt8aTL-vi%B{rd@(b46t@nC6f*lr{R*qjaVI1H|-U_&IPa56TVHnUw5TRVKnt z@OAGezXm=I3!vhB5!2?ze5gCg|Ap->iJ#O~>gQPLXVdLgt=wBG&gVYLzB~f;e+gZtEg3szFN7BOTTp%{`Tj9f zJ^r7`^s$bNs?<0B-PkcrzV=<@r@<(=KiKvn`Sg0nwT5;X_LIL4u7PU@;m2&t+;^>; zTEG5D=-D1{au>;&{*6`|YkG1%*wXxJ1#Wsp>G^blb znO}!7E*C^~D)rm==bX`>!1V`VX5>PppepA;Cwv#)0{{NIi~Mr1?TcW7^DT=-#kBa^ z){!qn+mkub04cPd<|Or`wQpstOfDS>wDi|Max{-aL+AF!e3YWd!cD)aDd*PWf<>t`-5XI9aF z6y;9@b#1P3`Ecmpq%s%ZHg>`>v6-@CD)d`{3fJ>YU#;IC-yUv076Y4LbwH;0d>xhQTP>y~gLv2O6ioIRdnU%$!*}h#-?1|n&tZ3PREtY#{ zr?%ws8r$yJuIG)V)w|l>TsB}=$M$Mxi=pkd`dTQ3R^5Ke<$l}5el0JuBCuVA#LAr| z!*A&0WE00$m;>o|T4TxNzdQnY1V&B-RG0IdR14pv-sy>Oo?Dw>7OaNb;YL`HY)`FQ zr>CtLtf>UgyY)S1e=FH9zHH6c*1!C<$eWYxkyWwv#oqzX=n;R%PR`b;c|F3O^CSPN zax|=m#P@#EyW#rmzN59cH>BUctt&3g*2(yP5FeMqvMO9g*)(g5A6qxr35owZNY8+_ z?7n5C^!rX?@EghsX}xUxf0&jJlE9CxU;GQqzpY(o-#uDed(WiO4d&O@bbI6UKd}Al z3Pa<+vqG-QO#Jl3KDS^Wiqm!szMj^9=lplY{;dBu!zEsR9qB@(A?yFA9NdY}9?(Yq z{fTC+jDNQODkb*+B$#jOFYCF{y8_k(qq3KkI{eRK|8K^= zUcvG{B03j0#AT-Ik}CbDJaaxIdA>Q&Pu1c72cDnDNgwHa$#q^i?_%YMz~2kx*8;~U z!@Z*2)64FfcZRz4_BXJ}_+LeO-UfR==3>7AYkf3UCAvv}9_UMY3_gsXq>pA-Lj#Pe zfSq*n4nkEnLm$|AXa0@7un+%Nh)M@=93vlg&ZTi9%{WTmyR#lH-@}jZf6((^o&RUC zcD?ic@3XS?yrUD9tDq0;oM-(}KIrwoNZ#UZzHR%)I{g=v^BOJ!KmON{Kl>4Tck15H ze-e8R%z*3~pc{+H_633eyU5Rl`BgZJvS#ax9_;)}J6uB|8-HS=y9Zzo<&YH6G^<9!B47E8yB%S$~I-h>|Nl}mkHWod>MRyJU;pG`z%XCdmnyDz;>Wu9jum}7MR@`sS1SxV!+H;So5JcIJOO{0LS9^OWZ%Jnga#K3WAy%0H>%86mDl0Ht@OK*?=Yyp7Z$-B_%E!Y zjkSLi#sPY^y>G3Ko1lFE0ydZ0{TSauj|S>*gUK)+o`I)mW9>JM_SF&nK`P8vNL1(yCvi zjj=H1JnWfM^ri-4&^Tz@%#Plj8LlL(CHK=fUF2imhQ%UBUnEjkCem>f`7V)0uU^vT Ee-Z|xX79iI-0?k8yJZfa{t7K)wM0Kll)N z5db2Q0I=i>04MSQK!#Y@>Uxr&fL?a6w*mJ586_Q$bNGl*1lG}3hyw-*NbA<%Eerra zBp+*I=@B!&GI=T0$5xTPu{Igid6G)Q>vJ^* zn(Z5v&YFw@5zSw%tYCLkSSz7&TPn;dEkN$o&VwE?+u{RNru>}>5f%64E)CtZ5+6p?wbBe;52BE$=b3I zXZHuUxDj9oj){0`3VqKLXLBUkAHgAx)c?q*Si2d4Q_6zVuYGHfP03r!ux>E5-q_!p z`4ndm5uYO&$h?~OiaSahs!hAC+BFxgs352xUCuSfvlqjr1(lMoDs|dD5_uxv6*LuQ zx^99p@8(L(BG*+>KVytyCLHs9^MMfU4!c(pBKeXO> zF++joh*GZNoKV_VI+|nTz>_oaC^mFLonZtH)Qf(ca{ZLGEH*VOyxw!s(7A}8Z2p7% zT=c^8^0eUG3 zl3%vUc+;;t20AZBl_LNW+53C(vN26PdL9l?_*SP9`^xl2{UanP8A;93;n6x-@wCy=| zbjzRZV$fm5=i;4%Ef8tiG!QbnaM*xocfa}-}#%5@g(sZMBsqzRlZ;T_j7 z&P?h$JkM1R+08L~xq)L_jHJ2X_<~;m;BkU;54Ur=Wl5X*QIc{h{h#ixe<({!Bz@`J zf6$4Tbve#FuxK;y84Z4rzp!_navr@JE-zcxDzem1FFlf$Rbj(wN^d;GIcL_p_w6@e z*I^{=_fm{b6W=Bg1pYa&$YXx?C!r7A^Ix+w(s# zwOgLl2FHDP7pAc$e3Sob%!5$49%MF?>VD0DFvF6;m?Bojof(R*DKNx2QBs46ab^^R zWj96p>@Qfsmxo8^w*wi>eBRFUr&~9+p}pPIXU>WtiC3y*Q=U+(r!~W}Cd`?kmzF@_ zl-#??b0IU)X0bt?sZ!6jf%mE~L~>1U^(6FjWAmz5d8j;BDayT-K}WsFpIei8mU_?L zHCIHNla~}fw-#P)D{BHBGyJaAB97WuZ3vAmBEjv(3C87Z6B&;XYV;p@gWngoMibEV zfGhCTGnH`3G{Et~` z^O$@a!E{BDVq!ZUh1`+6f|;ahVP~bZC=LyH8psRCaN*W*70Ob03O>JP6y5j&E*Vbp zNARk)N`i!C7LV5X$$iEonPJVsLj=shzZE(q)G@86YIA0PN0?R_@o&p|P|ESf94yv4 z^{wa#skdp|mn|D?@c#PIGSL+5B-Lp}eRzHMwmgyuYfc2-dHLKiWp5}pLIv4`9 zE52?LTjSnGRr>L8lIl+v^L%wPA&@P311Zi$?CR@q+GK*Jn$_IC-0nN|(NTvg;#)gH z@Mo#GUB>-PKHLdgKZ;s~C~4pyglcLuG+n)AsQszdr#U@)@GfukO9yKUQR;G{V|;iy z@<5Zc8moq8UEx6ga|pX{-VN1hxvD^r*=WPq8_-lB+0f6HKgo@_MYA%U;f{t&_98PR z+>~Pg_-XET^2|Z@?S;fs=Jb031UeL+K&WddOoQNYn|G=?O!$E`NhWGF|s0 z|BDb37C;D${ojN~F#ETB0`9tpcZ8c?v@($t7Dx!jD@Vi<@yY~Zgg@Vl7S3+Mgs*vN zYcrht`mr200IVja?=L2;Chd@O04%L+LpJOR0IM~4DEECF?tR_=8u41FkBlifqYo^} VNJVT~9jEe309acm8=94G(%&eC^!ES& literal 0 HcmV?d00001 diff --git a/ErsatzTV/wwwroot/site.webmanifest b/ErsatzTV/wwwroot/site.webmanifest new file mode 100644 index 000000000..b20abb7cb --- /dev/null +++ b/ErsatzTV/wwwroot/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +}