From 9ba0cbd84f43b23b483a6654cb10e16e0b85a08f Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sun, 14 Mar 2021 16:03:04 +0000 Subject: [PATCH] enable plex for television (#73) * add plex show, season sync * sync plex episodes * sync plex episode statistics * update plex artwork as needed * code cleanup * add note about tests --- .../SynchronizePlexLibraryByIdHandler.cs | 9 +- ...layoutItemProcessByChannelNumberHandler.cs | 10 +- .../Fakes/FakeTelevisionRepository.cs | 11 + ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs | 7 + ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs | 7 + ErsatzTV.Core/Domain/MediaItem/PlexShow.cs | 7 + ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs | 5 +- .../Interfaces/Plex/IPlexServerApiClient.cs | 21 +- .../Plex/IPlexTelevisionLibraryScanner.cs | 15 + .../Repositories/IMetadataRepository.cs | 15 + .../Repositories/ITelevisionRepository.cs | 4 + ErsatzTV.Core/Plex/PlexLibraryScanner.cs | 58 + ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs | 51 +- .../Plex/PlexTelevisionLibraryScanner.cs | 281 +++ ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 2 + .../MediaItem/PlexEpisodeConfiguration.cs | 11 + .../MediaItem/PlexSeasonConfiguration.cs | 11 + .../MediaItem/PlexShowConfiguration.cs | 11 + .../Data/Repositories/MediaItemRepository.cs | 28 +- .../Repositories/MediaSourceRepository.cs | 27 + .../Data/Repositories/MetadataRepository.cs | 78 + .../Data/Repositories/TelevisionRepository.cs | 121 +- ErsatzTV.Infrastructure/Data/TvContext.cs | 3 + ...10314023600_Add_PlexTelevision.Designer.cs | 1686 +++++++++++++++++ .../20210314023600_Add_PlexTelevision.cs | 79 + .../Migrations/TvContextModelSnapshot.cs | 69 + .../Plex/IPlexServerApi.cs | 7 + .../Plex/Models/PlexMetadataResponse.cs | 1 + .../Plex/PlexServerApiClient.cs | 246 ++- ErsatzTV/Controllers/ArtworkController.cs | 84 +- ErsatzTV/Startup.cs | 2 + 31 files changed, 2855 insertions(+), 112 deletions(-) create mode 100644 ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs create mode 100644 ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs create mode 100644 ErsatzTV.Core/Domain/MediaItem/PlexShow.cs create mode 100644 ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs create mode 100644 ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs create mode 100644 ErsatzTV.Core/Plex/PlexLibraryScanner.cs create mode 100644 ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexEpisodeConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexSeasonConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexShowConfiguration.cs create mode 100644 ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.Designer.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.cs diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs index e538408a7..b78518025 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs @@ -24,17 +24,20 @@ namespace ErsatzTV.Application.Plex.Commands private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner; private readonly IPlexSecretStore _plexSecretStore; + private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner; public SynchronizePlexLibraryByIdHandler( IMediaSourceRepository mediaSourceRepository, IPlexSecretStore plexSecretStore, IPlexMovieLibraryScanner plexMovieLibraryScanner, + IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner, IEntityLocker entityLocker, ILogger logger) { _mediaSourceRepository = mediaSourceRepository; _plexSecretStore = plexSecretStore; _plexMovieLibraryScanner = plexMovieLibraryScanner; + _plexTelevisionLibraryScanner = plexTelevisionLibraryScanner; _entityLocker = entityLocker; _logger = logger; } @@ -67,8 +70,10 @@ namespace ErsatzTV.Application.Plex.Commands parameters.Library); break; case LibraryMediaKind.Shows: - // TODO: plex tv scanner - // await _televisionFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath); + await _plexTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection, + parameters.ConnectionParameters.PlexServerAuthToken, + parameters.Library); break; } diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index d176382aa..a40054dbf 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -158,12 +158,12 @@ namespace ErsatzTV.Application.Streaming.Queries MediaFile file = version.MediaFiles.Head(); string path = file.Path; - if (playoutItem.MediaItem is PlexMovie plexMovie) + return playoutItem.MediaItem switch { - path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path); - } - - return path; + PlexMovie plexMovie => await GetReplacementPlexPath(plexMovie.LibraryPathId, path), + PlexEpisode plexEpisode => await GetReplacementPlexPath(plexEpisode.LibraryPathId, path), + _ => path + }; } private async Task GetReplacementPlexPath(int libraryPathId, string path) diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index fd7efde37..6983d3871 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -66,5 +66,16 @@ namespace ErsatzTV.Core.Tests.Fakes public Task DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException(); public Task DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException(); + + public Task> GetOrAddPlexShow(PlexLibrary library, PlexShow item) => + throw new NotSupportedException(); + + public Task> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) => + throw new NotSupportedException(); + + public Task> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) => + throw new NotSupportedException(); + + public Task AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException(); } } diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs b/ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs new file mode 100644 index 000000000..604c273f2 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexEpisode : Episode + { + public string Key { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs b/ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs new file mode 100644 index 000000000..a6faaeb48 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexSeason : Season + { + public string Key { get; set; } + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexShow.cs b/ErsatzTV.Core/Domain/MediaItem/PlexShow.cs new file mode 100644 index 000000000..6d4041a78 --- /dev/null +++ b/ErsatzTV.Core/Domain/MediaItem/PlexShow.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Domain +{ + public class PlexShow : Show + { + public string Key { get; set; } + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs index 5f1d2d8cd..23cf4c3f0 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs @@ -218,13 +218,14 @@ namespace ErsatzTV.Core.FFmpeg public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text) { const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf"; - const string FONT_SIZE = "fontsize=60"; const string FONT_COLOR = "fontcolor=white"; const string X = "x=(w-text_w)/2"; const string Y = "y=(h-text_h)/3*2"; + string fontSize = text.Length > 60 ? "fontsize=40" : "fontsize=60"; + return WithFilterComplex( - $"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]", + $"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]", "[v]", "1:a"); } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs index ca7ae9364..4f0533c27 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs @@ -12,13 +12,30 @@ namespace ErsatzTV.Core.Interfaces.Plex PlexConnection connection, PlexServerAuthToken token); - Task>> GetLibraryContents( + Task>> GetMovieLibraryContents( PlexLibrary library, PlexConnection connection, PlexServerAuthToken token); + Task>> GetShowLibraryContents( + PlexLibrary library, + PlexConnection connection, + PlexServerAuthToken token); + + Task>> GetShowSeasons( + PlexLibrary library, + PlexShow show, + PlexConnection connection, + PlexServerAuthToken token); + + Task>> GetSeasonEpisodes( + PlexLibrary library, + PlexSeason season, + PlexConnection connection, + PlexServerAuthToken token); + Task> GetStatistics( - PlexMovie movie, + string key, PlexConnection connection, PlexServerAuthToken token); } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs new file mode 100644 index 000000000..2d2c19e86 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Plex; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Plex +{ + public interface IPlexTelevisionLibraryScanner + { + Task> ScanLibrary( + PlexConnection connection, + PlexServerAuthToken token, + PlexLibrary plexMediaSourceLibrary); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs new file mode 100644 index 000000000..fffb06e3c --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Repositories +{ + public interface IMetadataRepository + { + Task RemoveGenre(Genre genre); + Task UpdateStatistics(MediaVersion mediaVersion); + Task UpdateArtworkPath(Artwork artwork); + Task AddArtwork(Domain.Metadata metadata, Artwork artwork); + Task RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind); + } +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index cfcf7e86a..a30855055 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -32,5 +32,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task DeleteByPath(LibraryPath libraryPath, string path); Task DeleteEmptySeasons(LibraryPath libraryPath); Task DeleteEmptyShows(LibraryPath libraryPath); + Task> GetOrAddPlexShow(PlexLibrary library, PlexShow item); + Task> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item); + Task> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item); + Task AddGenre(ShowMetadata metadata, Genre genre); } } diff --git a/ErsatzTV.Core/Plex/PlexLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexLibraryScanner.cs new file mode 100644 index 000000000..f20c7fb9f --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexLibraryScanner.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Plex +{ + public abstract class PlexLibraryScanner + { + private readonly IMetadataRepository _metadataRepository; + + protected PlexLibraryScanner(IMetadataRepository metadataRepository) => + _metadataRepository = metadataRepository; + + protected async Task UpdateArtworkIfNeeded( + Domain.Metadata existingMetadata, + Domain.Metadata incomingMetadata, + ArtworkKind artworkKind) + { + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + { + Option maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); + + await maybeIncomingArtwork.Match( + async incomingArtwork => + { + Option maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); + + await maybeExistingArtwork.Match( + async existingArtwork => + { + existingArtwork.Path = incomingArtwork.Path; + existingArtwork.DateUpdated = incomingArtwork.DateUpdated; + await _metadataRepository.UpdateArtworkPath(existingArtwork); + }, + async () => + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.Add(incomingArtwork); + await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork); + }); + }, + async () => + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); + await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind); + }); + } + + return Unit.Default; + } + } +} diff --git a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs index 0497f9dd3..e7fb1c462 100644 --- a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs @@ -10,7 +10,7 @@ using static LanguageExt.Prelude; namespace ErsatzTV.Core.Plex { - public class PlexMovieLibraryScanner : IPlexMovieLibraryScanner + public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner { private readonly ILogger _logger; private readonly IMovieRepository _movieRepository; @@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Plex public PlexMovieLibraryScanner( IPlexServerApiClient plexServerApiClient, IMovieRepository movieRepository, + IMetadataRepository metadataRepository, ILogger logger) + : base(metadataRepository) { _plexServerApiClient = plexServerApiClient; _movieRepository = movieRepository; @@ -31,7 +33,7 @@ namespace ErsatzTV.Core.Plex PlexServerAuthToken token, PlexLibrary plexMediaSourceLibrary) { - Either> entries = await _plexServerApiClient.GetLibraryContents( + Either> entries = await _plexServerApiClient.GetMovieLibraryContents( plexMediaSourceLibrary, connection, token); @@ -71,8 +73,6 @@ namespace ErsatzTV.Core.Plex return Task.CompletedTask; }); - // need plex media item model that can be used to lookup by unique id (metadata key?) - return Unit.Default; } @@ -89,7 +89,7 @@ namespace ErsatzTV.Core.Plex string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio)) { Either maybeStatistics = - await _plexServerApiClient.GetStatistics(incoming, connection, token); + await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token); maybeStatistics.IfRight( mediaVersion => @@ -128,51 +128,18 @@ namespace ErsatzTV.Core.Plex return Right(existing).AsTask(); } - private Task> UpdateArtwork(PlexMovie existing, PlexMovie incoming) + private async Task> UpdateArtwork(PlexMovie existing, PlexMovie incoming) { MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) { - UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); - UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); } - return Right(existing).AsTask(); - } - - private void UpdateArtworkIfNeeded( - MovieMetadata existingMetadata, - MovieMetadata incomingMetadata, - ArtworkKind artworkKind) - { - Option maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten() - .Find(a => a.ArtworkKind == artworkKind); - - maybeIncomingArtwork.Match( - incomingArtwork => - { - Option maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten() - .Find(a => a.ArtworkKind == artworkKind); - - maybeExistingArtwork.Match( - existingArtwork => - { - existingArtwork.Path = incomingArtwork.Path; - existingArtwork.DateUpdated = incomingArtwork.DateUpdated; - }, - () => - { - existingMetadata.Artwork ??= new List(); - existingMetadata.Artwork.Add(incomingArtwork); - }); - }, - () => - { - existingMetadata.Artwork ??= new List(); - existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); - }); + return existing; } } } diff --git a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs new file mode 100644 index 000000000..dadf7e2af --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs @@ -0,0 +1,281 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using Microsoft.Extensions.Logging; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Plex +{ + public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner + { + private readonly ILogger _logger; + private readonly IMetadataRepository _metadataRepository; + private readonly IPlexServerApiClient _plexServerApiClient; + private readonly ITelevisionRepository _televisionRepository; + + public PlexTelevisionLibraryScanner( + IPlexServerApiClient plexServerApiClient, + ITelevisionRepository televisionRepository, + IMetadataRepository metadataRepository, + ILogger logger) + : base(metadataRepository) + { + _plexServerApiClient = plexServerApiClient; + _televisionRepository = televisionRepository; + _metadataRepository = metadataRepository; + _logger = logger; + } + + public async Task> ScanLibrary( + PlexConnection connection, + PlexServerAuthToken token, + PlexLibrary plexMediaSourceLibrary) + { + Either> entries = await _plexServerApiClient.GetShowLibraryContents( + plexMediaSourceLibrary, + connection, + token); + + return await entries.Match>>( + async showEntries => + { + foreach (PlexShow incoming in showEntries) + { + // TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper? + // TODO: figure out how to rebuild playlists + Either maybeShow = await _televisionRepository + .GetOrAddPlexShow(plexMediaSourceLibrary, incoming) + .BindT(existing => UpdateMetadata(existing, incoming)) + .BindT(existing => UpdateArtwork(existing, incoming)); + + await maybeShow.Match( + async show => await ScanSeasons(plexMediaSourceLibrary, show, connection, token), + error => + { + _logger.LogWarning( + "Error processing plex show at {Key}: {Error}", + incoming.Key, + error.Value); + return Task.CompletedTask; + }); + } + + // TODO: delete removed shows + + return Unit.Default; + }, + error => + { + _logger.LogWarning( + "Error synchronizing plex library {Path}: {Error}", + plexMediaSourceLibrary.Name, + error.Value); + + return Left(error).AsTask(); + }); + } + + private Task> UpdateMetadata(PlexShow existing, PlexShow incoming) + { + ShowMetadata existingMetadata = existing.ShowMetadata.Head(); + ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); + + // TODO: this probably doesn't work + // plex doesn't seem to update genres returned by the main library call + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + { + foreach (Genre genre in existingMetadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Remove(genre); + _metadataRepository.RemoveGenre(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Add(genre); + _televisionRepository.AddGenre(existingMetadata, genre); + } + } + + return Right(existing).AsTask(); + } + + private async Task> UpdateArtwork(PlexShow existing, PlexShow incoming) + { + ShowMetadata existingMetadata = existing.ShowMetadata.Head(); + ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); + + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + { + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); + } + + return existing; + } + + private async Task> ScanSeasons( + PlexLibrary plexMediaSourceLibrary, + PlexShow show, + PlexConnection connection, + PlexServerAuthToken token) + { + Either> entries = await _plexServerApiClient.GetShowSeasons( + plexMediaSourceLibrary, + show, + connection, + token); + + return await entries.Match>>( + async seasonEntries => + { + foreach (PlexSeason incoming in seasonEntries) + { + incoming.ShowId = show.Id; + + // TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper? + // TODO: figure out how to rebuild playlists + Either maybeSeason = await _televisionRepository + .GetOrAddPlexSeason(plexMediaSourceLibrary, incoming) + .BindT(existing => UpdateArtwork(existing, incoming)); + + await maybeSeason.Match( + async season => await ScanEpisodes(plexMediaSourceLibrary, season, connection, token), + error => + { + _logger.LogWarning( + "Error processing plex show at {Key}: {Error}", + incoming.Key, + error.Value); + return Task.CompletedTask; + }); + } + + // TODO: delete removed seasons + + return Unit.Default; + }, + error => + { + _logger.LogWarning( + "Error synchronizing plex library {Path}: {Error}", + plexMediaSourceLibrary.Name, + error.Value); + + return Left(error).AsTask(); + }); + } + + private async Task> UpdateArtwork(PlexSeason existing, PlexSeason incoming) + { + SeasonMetadata existingMetadata = existing.SeasonMetadata.Head(); + SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head(); + + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + { + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); + } + + return existing; + } + + private async Task> ScanEpisodes( + PlexLibrary plexMediaSourceLibrary, + PlexSeason season, + PlexConnection connection, + PlexServerAuthToken token) + { + Either> entries = await _plexServerApiClient.GetSeasonEpisodes( + plexMediaSourceLibrary, + season, + connection, + token); + + return await entries.Match>>( + async episodeEntries => + { + foreach (PlexEpisode incoming in episodeEntries) + { + incoming.SeasonId = season.Id; + + // TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper? + // TODO: figure out how to rebuild playlists + Either maybeEpisode = await _televisionRepository + .GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming) + .BindT(existing => UpdateStatistics(existing, incoming, connection, token)) + .BindT(existing => UpdateArtwork(existing, incoming)); + + maybeEpisode.IfLeft( + error => _logger.LogWarning( + "Error processing plex episode at {Key}: {Error}", + incoming.Key, + error.Value)); + } + + // TODO: delete removed episodes + + return Unit.Default; + }, + error => + { + _logger.LogWarning( + "Error synchronizing plex library {Path}: {Error}", + plexMediaSourceLibrary.Name, + error.Value); + + return Left(error).AsTask(); + }); + } + + private async Task> UpdateStatistics( + PlexEpisode existing, + PlexEpisode incoming, + PlexConnection connection, + PlexServerAuthToken token) + { + MediaVersion existingVersion = existing.MediaVersions.Head(); + MediaVersion incomingVersion = incoming.MediaVersions.Head(); + + if (incomingVersion.DateUpdated > existingVersion.DateUpdated || + string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio)) + { + Either maybeStatistics = + await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token); + + await maybeStatistics.Match( + async mediaVersion => + { + existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1"; + existingVersion.VideoScanKind = mediaVersion.VideoScanKind; + existingVersion.DateUpdated = incomingVersion.DateUpdated; + + await _metadataRepository.UpdateStatistics(existingVersion); + }, + _ => Task.CompletedTask); + } + + return Right(existing); + } + + private async Task> UpdateArtwork(PlexEpisode existing, PlexEpisode incoming) + { + EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head(); + EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head(); + + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + { + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail); + } + + return existing; + } + } +} diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index e19be93ab..d9e4677e4 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -13,6 +13,8 @@ using Map = LanguageExt.Map; namespace ErsatzTV.Core.Scheduling { + // TODO: these tests fail on days when offset changes + // because the change happens during the playout public class PlayoutBuilder : IPlayoutBuilder { private static readonly Random Random = new(); diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexEpisodeConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexEpisodeConfiguration.cs new file mode 100644 index 000000000..67574162c --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexEpisodeConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexEpisodeConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexEpisode"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexSeasonConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexSeasonConfiguration.cs new file mode 100644 index 000000000..3e6eeb419 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexSeasonConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexSeasonConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexSeason"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexShowConfiguration.cs b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexShowConfiguration.cs new file mode 100644 index 000000000..f1a9ed8c0 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexShowConfiguration.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ErsatzTV.Infrastructure.Data.Configurations +{ + public class PlexShowConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) => builder.ToTable("PlexShow"); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index 7df821faf..b76acc0bc 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Data; using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; @@ -11,18 +12,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { public class MediaItemRepository : IMediaItemRepository { - private readonly TvContext _dbContext; + private readonly IDbConnection _dbConnection; + private readonly IDbContextFactory _dbContextFactory; - public MediaItemRepository(TvContext dbContext) => _dbContext = dbContext; + public MediaItemRepository(IDbContextFactory dbContextFactory, IDbConnection dbConnection) + { + _dbContextFactory = dbContextFactory; + _dbConnection = dbConnection; + } - public Task> Get(int id) => - _dbContext.MediaItems + public async Task> Get(int id) + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + return await context.MediaItems .Include(i => i.LibraryPath) .OrderBy(i => i.Id) .SingleOrDefaultAsync(i => i.Id == id) .Map(Optional); + } - public Task> GetAll() => _dbContext.MediaItems.ToListAsync(); + public async Task> GetAll() + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + return await context.MediaItems.ToListAsync(); + } public Task> Search(string searchString) => // TODO: fix this when we need to search @@ -47,8 +60,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public async Task Update(MediaItem mediaItem) { - _dbContext.MediaItems.Update(mediaItem); - return await _dbContext.SaveChangesAsync() > 0; + await using TvContext context = _dbContextFactory.CreateDbContext(); + context.MediaItems.Update(mediaItem); + return await context.SaveChangesAsync() > 0; } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index b3eeba79e..c8a5a4725 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -195,6 +195,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories INNER JOIN Library l ON l.Id = lp.LibraryId WHERE l.Id IN @ids)", new { ids = libraryIds }); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN PlexEpisode pe ON pe.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids)", + new { ids = libraryIds }); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN PlexSeason ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids)", + new { ids = libraryIds }); + + await _dbConnection.ExecuteAsync( + @"DELETE FROM MediaItem WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN PlexShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId + INNER JOIN Library l ON l.Id = lp.LibraryId + WHERE l.Id IN @ids)", + new { ids = libraryIds }); } public Task EnablePlexLibrarySync(IEnumerable libraryIds) => diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs new file mode 100644 index 000000000..af75f2a4b --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs @@ -0,0 +1,78 @@ +using System.Data; +using System.Threading.Tasks; +using Dapper; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; + +namespace ErsatzTV.Infrastructure.Data.Repositories +{ + public class MetadataRepository : IMetadataRepository + { + private readonly IDbConnection _dbConnection; + + public MetadataRepository(IDbConnection dbConnection) => _dbConnection = dbConnection; + + public Task RemoveGenre(Genre genre) => + _dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }).ToUnit(); + + public Task UpdateStatistics(MediaVersion mediaVersion) => + _dbConnection.ExecuteAsync( + @"UPDATE MediaVersion SET + SampleAspectRatio = @SampleAspectRatio, + VideoScanKind = @VideoScanKind, + DateUpdated = @DateUpdated + WHERE Id = @MediaVersionId", + new + { + mediaVersion.SampleAspectRatio, + mediaVersion.VideoScanKind, + mediaVersion.DateUpdated, + MediaVersionId = mediaVersion.Id + }).ToUnit(); + + public Task UpdateArtworkPath(Artwork artwork) => + _dbConnection.ExecuteAsync( + "UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id", + new { artwork.Path, artwork.DateUpdated, artwork.Id }).ToUnit(); + + public Task AddArtwork(Metadata metadata, Artwork artwork) + { + var parameters = new + { + artwork.ArtworkKind, metadata.Id, artwork.DateAdded, artwork.DateUpdated, artwork.Path + }; + + return metadata switch + { + MovieMetadata => _dbConnection.ExecuteAsync( + @"INSERT INTO Artwork (ArtworkKind, MovieMetadataId, DateAdded, DateUpdated, Path) + VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)", + parameters) + .ToUnit(), + ShowMetadata => _dbConnection.ExecuteAsync( + @"INSERT INTO Artwork (ArtworkKind, ShowMetadataId, DateAdded, DateUpdated, Path) + VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)", + parameters) + .ToUnit(), + SeasonMetadata => _dbConnection.ExecuteAsync( + @"INSERT INTO Artwork (ArtworkKind, SeasonMetadataId, DateAdded, DateUpdated, Path) + VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)", + parameters) + .ToUnit(), + EpisodeMetadata => _dbConnection.ExecuteAsync( + @"INSERT INTO Artwork (ArtworkKind, EpisodeMetadataId, DateAdded, DateUpdated, Path) + VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)", + parameters) + .ToUnit(), + _ => Task.FromResult(Unit.Default) + }; + } + + public Task RemoveArtwork(Metadata metadata, ArtworkKind artworkKind) => + _dbConnection.ExecuteAsync( + @"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id + OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)", + new { ArtworkKind = artworkKind, metadata.Id }).ToUnit(); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index f9392bb9e..bead0b75a 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -17,11 +17,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { private readonly IDbConnection _dbConnection; private readonly TvContext _dbContext; + private readonly IDbContextFactory _dbContextFactory; - public TelevisionRepository(TvContext dbContext, IDbConnection dbConnection) + public TelevisionRepository( + TvContext dbContext, + IDbConnection dbConnection, + IDbContextFactory dbContextFactory) { _dbContext = dbContext; _dbConnection = dbConnection; + _dbContextFactory = dbContextFactory; } public Task AllShowsExist(List showIds) => @@ -305,6 +310,60 @@ namespace ErsatzTV.Infrastructure.Data.Repositories }) .ToUnit(); + public async Task> GetOrAddPlexShow(PlexLibrary library, PlexShow item) + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await context.PlexShows + .AsNoTracking() + .Include(i => i.ShowMetadata) + .ThenInclude(mm => mm.Genres) + .Include(i => i.ShowMetadata) + .ThenInclude(mm => mm.Artwork) + .OrderBy(i => i.Key) + .SingleOrDefaultAsync(i => i.Key == item.Key); + + return await maybeExisting.Match( + plexShow => Right(plexShow).AsTask(), + async () => await AddPlexShow(context, library, item)); + } + + public async Task> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await context.PlexSeasons + .AsNoTracking() + .Include(i => i.SeasonMetadata) + .ThenInclude(mm => mm.Artwork) + .OrderBy(i => i.Key) + .SingleOrDefaultAsync(i => i.Key == item.Key); + + return await maybeExisting.Match( + plexSeason => Right(plexSeason).AsTask(), + async () => await AddPlexSeason(context, library, item)); + } + + public async Task> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + Option maybeExisting = await context.PlexEpisodes + .AsNoTracking() + .Include(i => i.EpisodeMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .OrderBy(i => i.Key) + .SingleOrDefaultAsync(i => i.Key == item.Key); + + return await maybeExisting.Match( + plexEpisode => Right(plexEpisode).AsTask(), + async () => await AddPlexEpisode(context, library, item)); + } + + public Task AddGenre(ShowMetadata metadata, Genre genre) => + _dbConnection.ExecuteAsync( + "INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)", + new { genre.Name, MetadataId = metadata.Id }).ToUnit(); + public async Task> GetShowItems(int showId) { IEnumerable ids = await _dbConnection.QueryAsync( @@ -381,5 +440,65 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return BaseError.New(ex.Message); } } + + private async Task> AddPlexShow( + TvContext context, + PlexLibrary library, + PlexShow item) + { + try + { + item.LibraryPathId = library.Paths.Head().Id; + + await context.PlexShows.AddAsync(item); + await context.SaveChangesAsync(); + await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + return item; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> AddPlexSeason( + TvContext context, + PlexLibrary library, + PlexSeason item) + { + try + { + item.LibraryPathId = library.Paths.Head().Id; + + await context.PlexSeasons.AddAsync(item); + await context.SaveChangesAsync(); + await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + return item; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task> AddPlexEpisode( + TvContext context, + PlexLibrary library, + PlexEpisode item) + { + try + { + item.LibraryPathId = library.Paths.Head().Id; + + await context.PlexEpisodes.AddAsync(item); + await context.SaveChangesAsync(); + await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + return item; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } } } diff --git a/ErsatzTV.Infrastructure/Data/TvContext.cs b/ErsatzTV.Infrastructure/Data/TvContext.cs index d0776254c..b2e3a9828 100644 --- a/ErsatzTV.Infrastructure/Data/TvContext.cs +++ b/ErsatzTV.Infrastructure/Data/TvContext.cs @@ -33,6 +33,9 @@ namespace ErsatzTV.Infrastructure.Data public DbSet Episodes { get; set; } public DbSet EpisodeMetadata { get; set; } public DbSet PlexMovies { get; set; } + public DbSet PlexShows { get; set; } + public DbSet PlexSeasons { get; set; } + public DbSet PlexEpisodes { get; set; } public DbSet Collections { get; set; } public DbSet CollectionItems { get; set; } public DbSet ProgramSchedules { get; set; } diff --git a/ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.Designer.cs new file mode 100644 index 000000000..c6b61568e --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.Designer.cs @@ -0,0 +1,1686 @@ +// +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("20210314023600_Add_PlexTelevision")] + partial class Add_PlexTelevision + { + 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("TEXT"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channel"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("UseCustomPlaybackOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Collection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b => + { + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("CustomIndex") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "MediaItemId"); + + b.HasIndex("MediaItemId"); + + b.ToTable("CollectionItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElement"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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("HardwareAcceleration") + .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.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Genre"); + }); + + 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("VideoProfile") + .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.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.ToTable("Tag"); + }); + + 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.PlexEpisode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexEpisode"); + }); + + 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.PlexSeason", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexSeason"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexShow", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexShow"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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.Genre", b => + { + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Genres") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Genres") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Genres") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Genres") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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.Tag", b => + { + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Tags") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Tags") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Tags") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Tags") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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.PlexEpisode", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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.PlexSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexSeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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"); + + b.Navigation("Genres"); + + b.Navigation("Tags"); + }); + + 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"); + + b.Navigation("Genres"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Tags"); + }); + + 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/20210314023600_Add_PlexTelevision.cs b/ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.cs new file mode 100644 index 000000000..a697228c3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210314023600_Add_PlexTelevision.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Add_PlexTelevision : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "PlexEpisode", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Key = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlexEpisode", x => x.Id); + table.ForeignKey( + "FK_PlexEpisode_Episode_Id", + x => x.Id, + "Episode", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "PlexSeason", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Key = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlexSeason", x => x.Id); + table.ForeignKey( + "FK_PlexSeason_Season_Id", + x => x.Id, + "Season", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + "PlexShow", + table => new + { + Id = table.Column("INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Key = table.Column("TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlexShow", x => x.Id); + table.ForeignKey( + "FK_PlexShow_Show_Id", + x => x.Id, + "Show", + "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "PlexEpisode"); + + migrationBuilder.DropTable( + "PlexSeason"); + + migrationBuilder.DropTable( + "PlexShow"); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index c7143c363..8a90abe9d 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -1044,6 +1044,18 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("ProgramScheduleOneItem"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexEpisode", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexEpisode"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.PlexMovie", b => @@ -1056,6 +1068,30 @@ namespace ErsatzTV.Infrastructure.Migrations b.ToTable("PlexMovie"); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexSeason", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexSeason"); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexShow", + b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexShow"); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.Artwork", b => @@ -1645,6 +1681,17 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexEpisode", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.PlexMovie", b => @@ -1656,6 +1703,28 @@ namespace ErsatzTV.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexSeason", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexSeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity( + "ErsatzTV.Core.Domain.PlexShow", + b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity( "ErsatzTV.Core.Domain.Channel", b => diff --git a/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs index 2ae419f40..4f5554603 100644 --- a/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs +++ b/ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs @@ -25,5 +25,12 @@ namespace ErsatzTV.Infrastructure.Plex string key, [Query] [AliasAs("X-Plex-Token")] string token); + + [Get("/library/metadata/{key}/children")] + public Task>> + GetChildren( + string key, + [Query] [AliasAs("X-Plex-Token")] + string token); } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs index 772e7eea7..eaf800bff 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs @@ -14,6 +14,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models public string OriginallyAvailableAt { get; set; } public int AddedAt { get; set; } public int UpdatedAt { get; set; } + public int Index { get; set; } public List Media { get; set; } public List Genre { get; set; } } diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index 98768629e..53d6ca2a6 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -42,7 +42,7 @@ namespace ErsatzTV.Infrastructure.Plex } } - public async Task>> GetLibraryContents( + public async Task>> GetMovieLibraryContents( PlexLibrary library, PlexConnection connection, PlexServerAuthToken token) @@ -60,15 +60,71 @@ namespace ErsatzTV.Infrastructure.Plex } } - public async Task> GetStatistics( - PlexMovie movie, + public async Task>> GetShowLibraryContents( + PlexLibrary library, PlexConnection connection, PlexServerAuthToken token) { try { IPlexServerApi service = RestService.For(connection.Uri); - return await service.GetMetadata(movie.Key.Split("/").Last(), token.AuthToken) + return await service.GetLibrarySectionContents(library.Key, token.AuthToken) + .Map(r => r.MediaContainer.Metadata) + .Map(list => list.Map(metadata => ProjectToShow(metadata, library.MediaSourceId)).ToList()); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + public async Task>> GetShowSeasons( + PlexLibrary library, + PlexShow show, + PlexConnection connection, + PlexServerAuthToken token) + { + try + { + IPlexServerApi service = RestService.For(connection.Uri); + return await service.GetChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken) + .Map(r => r.MediaContainer.Metadata) + .Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)).ToList()); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + public async Task>> GetSeasonEpisodes( + PlexLibrary library, + PlexSeason season, + PlexConnection connection, + PlexServerAuthToken token) + { + try + { + IPlexServerApi service = RestService.For(connection.Uri); + return await service.GetChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken) + .Map(r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)) + .Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)).ToList()); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + public async Task> GetStatistics( + string key, + PlexConnection connection, + PlexServerAuthToken token) + { + try + { + IPlexServerApi service = RestService.For(connection.Uri); + return await service.GetMetadata(key, token.AuthToken) .Map( r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0) .HeadOrNone()) @@ -202,5 +258,187 @@ namespace ErsatzTV.Infrastructure.Plex } }); } + + private PlexShow ProjectToShow(PlexMetadataResponse response, int mediaSourceId) + { + DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; + DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; + + var metadata = new ShowMetadata + { + Title = response.Title, + SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), + Plot = response.Summary, + ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt), + Year = response.Year, + Tagline = response.Tagline, + DateAdded = dateAdded, + DateUpdated = lastWriteTime, + Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList() + }; + + 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); + } + + if (!string.IsNullOrWhiteSpace(response.Art)) + { + var path = $"plex/{mediaSourceId}{response.Art}"; + var artwork = new Artwork + { + ArtworkKind = ArtworkKind.FanArt, + Path = path, + DateAdded = dateAdded, + DateUpdated = lastWriteTime + }; + + metadata.Artwork ??= new List(); + metadata.Artwork.Add(artwork); + } + + var show = new PlexShow + { + Key = response.Key, + ShowMetadata = new List { metadata } + }; + + return show; + } + + private PlexSeason ProjectToSeason(PlexMetadataResponse response, int mediaSourceId) + { + DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; + DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; + + var metadata = new SeasonMetadata + { + Title = response.Title, + SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), + Year = response.Year, + DateAdded = dateAdded, + DateUpdated = lastWriteTime + }; + + 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); + } + + if (!string.IsNullOrWhiteSpace(response.Art)) + { + var path = $"plex/{mediaSourceId}{response.Art}"; + var artwork = new Artwork + { + ArtworkKind = ArtworkKind.FanArt, + Path = path, + DateAdded = dateAdded, + DateUpdated = lastWriteTime + }; + + metadata.Artwork ??= new List(); + metadata.Artwork.Add(artwork); + } + + var season = new PlexSeason + { + Key = response.Key, + SeasonNumber = response.Index, + SeasonMetadata = new List { metadata } + }; + + return season; + } + + private PlexEpisode ProjectToEpisode(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 EpisodeMetadata + { + Title = response.Title, + SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), + Plot = response.Summary, + Year = response.Year, + Tagline = response.Tagline, + DateAdded = dateAdded, + DateUpdated = lastWriteTime + }; + + if (!string.IsNullOrWhiteSpace(response.OriginallyAvailableAt)) + { + metadata.ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt); + } + + if (!string.IsNullOrWhiteSpace(response.Thumb)) + { + var path = $"plex/{mediaSourceId}{response.Thumb}"; + var artwork = new Artwork + { + ArtworkKind = ArtworkKind.Thumbnail, + 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, + VideoProfile = media.VideoProfile, + // specifically omit sample aspect ratio + DateUpdated = lastWriteTime, + MediaFiles = new List + { + new PlexMediaFile + { + PlexId = part.Id, + Key = part.Key, + Path = part.File + } + } + }; + + var episode = new PlexEpisode + { + Key = response.Key, + EpisodeNumber = response.Index, + EpisodeMetadata = new List { metadata }, + MediaVersions = new List { version } + }; + + return episode; + } } } diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index 43b778694..ba4e116a6 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -49,56 +49,22 @@ namespace ErsatzTV.Controllers } [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"); - }); - } + public Task GetPlexPoster(int plexMediaSourceId, string path) => + GetPlexArtwork( + plexMediaSourceId, + $"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0"); [HttpGet("/artwork/fanart/plex/{plexMediaSourceId}/{*path}")] - public async Task GetPlexFanArt(int plexMediaSourceId, string path) - { - Either connectionParameters = - await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId)); + public Task GetPlexFanArt(int plexMediaSourceId, string path) => + GetPlexArtwork( + plexMediaSourceId, + $"/{path}"); - return await connectionParameters.Match>( - Left: _ => new NotFoundResult().AsTask(), - Right: async r => - { - HttpClient client = _httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken); - - var transcodePath = $"/{path}"; - var fullPath = new Uri(r.Uri, transcodePath); - HttpResponseMessage response = await client.GetAsync( - fullPath, - HttpCompletionOption.ResponseHeadersRead); - Stream stream = await response.Content.ReadAsStreamAsync(); - - return new FileStreamResult( - stream, - response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"); - }); - } + [HttpGet("/artwork/thumbnails/plex/{plexMediaSourceId}/{*path}")] + public Task GetPlexThumbnail(int plexMediaSourceId, string path) => + GetPlexArtwork( + plexMediaSourceId, + $"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0"); [HttpGet("/artwork/thumbnails/{fileName}")] public async Task GetThumbnail(string fileName) @@ -109,5 +75,29 @@ namespace ErsatzTV.Controllers Left: _ => new NotFoundResult(), Right: r => new FileContentResult(r.Contents, r.MimeType)); } + + private async Task GetPlexArtwork(int plexMediaSourceId, string transcodePath) + { + 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 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"); + }); + } } } diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 16ed35567..89c66ebf8 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -196,6 +196,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -206,6 +207,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHostedService();