diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c621011..0d2362919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add support for burning in embedded text subtitles - Add support for burning in external text subtitles - **This requires a one-time full library scan, which may take a long time with large libraries.** +- Sync Plex collections as tags on movies, shows, seasons and episodes + - This allows smart collections that use queries like `tag:"Plex Collection Name"` + +### Changed +- Improve the speed and change detection of the Plex library scanners ## [0.5.1-beta] - 2022-04-17 ### Fixed diff --git a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs index 45a8799ab..9047ce5bb 100644 --- a/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs @@ -59,42 +59,48 @@ public class SynchronizeEmbyLibraryByIdHandler : private async Task Synchronize(RequestParameters parameters) { - var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero); - DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); - if (parameters.ForceScan || nextScan < DateTimeOffset.Now) + try { - switch (parameters.Library.MediaKind) + var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero); + DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); + if (parameters.ForceScan || nextScan < DateTimeOffset.Now) { - case LibraryMediaKind.Movies: - await _embyMovieLibraryScanner.ScanLibrary( - parameters.ConnectionParameters.ActiveConnection.Address, - parameters.ConnectionParameters.ApiKey, - parameters.Library, - parameters.FFmpegPath, - parameters.FFprobePath); - break; - case LibraryMediaKind.Shows: - await _embyTelevisionLibraryScanner.ScanLibrary( - parameters.ConnectionParameters.ActiveConnection.Address, - parameters.ConnectionParameters.ApiKey, - parameters.Library, - parameters.FFmpegPath, - parameters.FFprobePath); - break; + switch (parameters.Library.MediaKind) + { + case LibraryMediaKind.Movies: + await _embyMovieLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.FFmpegPath, + parameters.FFprobePath); + break; + case LibraryMediaKind.Shows: + await _embyTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.FFmpegPath, + parameters.FFprobePath); + break; + } + + parameters.Library.LastScan = DateTime.UtcNow; + await _libraryRepository.UpdateLastScan(parameters.Library); + } + else + { + _logger.LogDebug( + "Skipping unforced scan of emby media library {Name}", + parameters.Library.Name); } - parameters.Library.LastScan = DateTime.UtcNow; - await _libraryRepository.UpdateLastScan(parameters.Library); + return Unit.Default; } - else + finally { - _logger.LogDebug( - "Skipping unforced scan of emby media library {Name}", - parameters.Library.Name); + _entityLocker.UnlockLibrary(parameters.Library.Id); } - - _entityLocker.UnlockLibrary(parameters.Library.Id); - return Unit.Default; } private async Task> Validate( diff --git a/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs index 0b94f602f..35e6c7e2e 100644 --- a/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs @@ -59,42 +59,48 @@ public class SynchronizeJellyfinLibraryByIdHandler : private async Task Synchronize(RequestParameters parameters) { - var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero); - DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); - if (parameters.ForceScan || nextScan < DateTimeOffset.Now) + try { - switch (parameters.Library.MediaKind) + var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero); + DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); + if (parameters.ForceScan || nextScan < DateTimeOffset.Now) { - case LibraryMediaKind.Movies: - await _jellyfinMovieLibraryScanner.ScanLibrary( - parameters.ConnectionParameters.ActiveConnection.Address, - parameters.ConnectionParameters.ApiKey, - parameters.Library, - parameters.FFmpegPath, - parameters.FFprobePath); - break; - case LibraryMediaKind.Shows: - await _jellyfinTelevisionLibraryScanner.ScanLibrary( - parameters.ConnectionParameters.ActiveConnection.Address, - parameters.ConnectionParameters.ApiKey, - parameters.Library, - parameters.FFmpegPath, - parameters.FFprobePath); - break; + switch (parameters.Library.MediaKind) + { + case LibraryMediaKind.Movies: + await _jellyfinMovieLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.FFmpegPath, + parameters.FFprobePath); + break; + case LibraryMediaKind.Shows: + await _jellyfinTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection.Address, + parameters.ConnectionParameters.ApiKey, + parameters.Library, + parameters.FFmpegPath, + parameters.FFprobePath); + break; + } + + parameters.Library.LastScan = DateTime.UtcNow; + await _libraryRepository.UpdateLastScan(parameters.Library); + } + else + { + _logger.LogDebug( + "Skipping unforced scan of jellyfin media library {Name}", + parameters.Library.Name); } - parameters.Library.LastScan = DateTime.UtcNow; - await _libraryRepository.UpdateLastScan(parameters.Library); + return Unit.Default; } - else + finally { - _logger.LogDebug( - "Skipping unforced scan of jellyfin media library {Name}", - parameters.Library.Name); + _entityLocker.UnlockLibrary(parameters.Library.Id); } - - _entityLocker.UnlockLibrary(parameters.Library.Id); - return Unit.Default; } private async Task> Validate( diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs index c53c2fb65..91671da43 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs @@ -65,99 +65,105 @@ public class ScanLocalLibraryHandler : IRequestHandler> Validate(IScanLocalLibrary request) diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs index fa730a188..f8d501e47 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs @@ -57,42 +57,48 @@ public class private async Task Synchronize(RequestParameters parameters) { - var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero); - DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); - if (parameters.ForceScan || nextScan < DateTimeOffset.Now) + try { - switch (parameters.Library.MediaKind) + var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero); + DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); + if (parameters.ForceScan || nextScan < DateTimeOffset.Now) { - case LibraryMediaKind.Movies: - await _plexMovieLibraryScanner.ScanLibrary( - parameters.ConnectionParameters.ActiveConnection, - parameters.ConnectionParameters.PlexServerAuthToken, - parameters.Library, - parameters.FFmpegPath, - parameters.FFprobePath); - break; - case LibraryMediaKind.Shows: - await _plexTelevisionLibraryScanner.ScanLibrary( - parameters.ConnectionParameters.ActiveConnection, - parameters.ConnectionParameters.PlexServerAuthToken, - parameters.Library, - parameters.FFmpegPath, - parameters.FFprobePath); - break; + switch (parameters.Library.MediaKind) + { + case LibraryMediaKind.Movies: + await _plexMovieLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection, + parameters.ConnectionParameters.PlexServerAuthToken, + parameters.Library, + parameters.FFmpegPath, + parameters.FFprobePath); + break; + case LibraryMediaKind.Shows: + await _plexTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters.ActiveConnection, + parameters.ConnectionParameters.PlexServerAuthToken, + parameters.Library, + parameters.FFmpegPath, + parameters.FFprobePath); + break; + } + + parameters.Library.LastScan = DateTime.UtcNow; + await _libraryRepository.UpdateLastScan(parameters.Library); + } + else + { + _logger.LogDebug( + "Skipping unforced scan of plex media library {Name}", + parameters.Library.Name); } - parameters.Library.LastScan = DateTime.UtcNow; - await _libraryRepository.UpdateLastScan(parameters.Library); + return Unit.Default; } - else + finally { - _logger.LogDebug( - "Skipping unforced scan of plex media library {Name}", - parameters.Library.Name); + _entityLocker.UnlockLibrary(parameters.Library.Id); } - - _entityLocker.UnlockLibrary(parameters.Library.Id); - return Unit.Default; } private async Task> Validate(ISynchronizePlexLibraryById request) => diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index f6c6e1fc1..52a004015 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -1,6 +1,7 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Tests.Fakes; @@ -73,7 +74,7 @@ public class FakeTelevisionRepository : ITelevisionRepository throw new NotSupportedException(); public Task AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException(); - public Task AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException(); + public Task AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException(); public Task AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException(); public Task AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException(); @@ -96,4 +97,12 @@ public class FakeTelevisionRepository : ITelevisionRepository public Task AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException(); public Task UpdatePath(int mediaFileId, string path) => throw new NotSupportedException(); + public Task SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException(); + + public Task SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException(); + + public Task SetPlexEtag(PlexEpisode episode, string etag) => throw new NotSupportedException(); + + public Task> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) => + throw new NotSupportedException(); } diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs b/ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs index ee1b8613f..04767322a 100644 --- a/ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs +++ b/ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs @@ -3,4 +3,5 @@ public class PlexEpisode : Episode { public string Key { get; set; } + public string Etag { get; set; } } diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs b/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs index f61d82c17..2663a4d12 100644 --- a/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs +++ b/ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs @@ -3,4 +3,5 @@ public class PlexMovie : Movie { public string Key { get; set; } + public string Etag { get; set; } } diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs b/ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs index fdc5d42b3..214392ffe 100644 --- a/ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs +++ b/ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs @@ -3,4 +3,5 @@ public class PlexSeason : Season { public string Key { get; set; } + public string Etag { get; set; } } diff --git a/ErsatzTV.Core/Domain/MediaItem/PlexShow.cs b/ErsatzTV.Core/Domain/MediaItem/PlexShow.cs index 76db93b8a..ff69e1ac7 100644 --- a/ErsatzTV.Core/Domain/MediaItem/PlexShow.cs +++ b/ErsatzTV.Core/Domain/MediaItem/PlexShow.cs @@ -3,4 +3,5 @@ public class PlexShow : Show { public string Key { get; set; } + public string Etag { get; set; } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index 613f54fbd..aa494e1c8 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -2,6 +2,7 @@ using ErsatzTV.Core.Emby; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Interfaces.Repositories; @@ -18,6 +19,7 @@ public interface IMovieRepository Task AddTag(MovieMetadata metadata, Tag tag); Task AddStudio(MovieMetadata metadata, Studio studio); Task AddActor(MovieMetadata metadata, Actor actor); + Task> GetExistingPlexMovies(PlexLibrary library); Task> RemoveMissingPlexMovies(PlexLibrary library, List movieKeys); Task UpdateSortTitle(MovieMetadata movieMetadata); Task> GetExistingJellyfinMovies(JellyfinLibrary library); @@ -31,4 +33,5 @@ public interface IMovieRepository Task AddDirector(MovieMetadata metadata, Director director); Task AddWriter(MovieMetadata metadata, Writer writer); Task UpdatePath(int mediaFileId, string path); + Task SetPlexEtag(PlexMovie movie, string etag); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index 34785e8ad..2faed202b 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Interfaces.Repositories; @@ -41,7 +42,7 @@ public interface ITelevisionRepository GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item); Task AddGenre(ShowMetadata metadata, Genre genre); - Task AddTag(ShowMetadata metadata, Tag tag); + Task AddTag(Domain.Metadata metadata, Tag tag); Task AddStudio(ShowMetadata metadata, Studio studio); Task AddActor(ShowMetadata metadata, Actor actor); Task AddActor(EpisodeMetadata metadata, Actor actor); @@ -52,4 +53,8 @@ public interface ITelevisionRepository Task AddDirector(EpisodeMetadata metadata, Director director); Task AddWriter(EpisodeMetadata metadata, Writer writer); Task UpdatePath(int mediaFileId, string path); + Task SetPlexEtag(PlexShow show, string etag); + Task SetPlexEtag(PlexSeason season, string etag); + Task SetPlexEtag(PlexEpisode episode, string etag); + Task> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season); } diff --git a/ErsatzTV.Core/Plex/PlexItemEtag.cs b/ErsatzTV.Core/Plex/PlexItemEtag.cs new file mode 100644 index 000000000..60ae18fe1 --- /dev/null +++ b/ErsatzTV.Core/Plex/PlexItemEtag.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Plex; + +public class PlexItemEtag +{ + public string Key { get; set; } + public string Etag { get; set; } +} diff --git a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs index 4ccb7babb..db830a395 100644 --- a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs @@ -60,6 +60,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan string ffmpegPath, string ffprobePath) { + List existingMovies = await _movieRepository.GetExistingPlexMovies(library); + List pathReplacements = await _mediaSourceRepository .GetPlexPathReplacements(library.MediaSourceId); @@ -94,6 +96,15 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count; await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); + Option maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key); + if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == incoming.Etag) + { + // _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key); + continue; + } + + _logger.LogDebug("UPDATE: Etag has changed for movie {Movie}", incoming.MovieMetadata.Head().Title); + // TODO: figure out how to rebuild playlists Either> maybeMovie = await _movieRepository .GetOrAdd(library, incoming) @@ -106,6 +117,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan await maybeMovie.Match( async result => { + await _movieRepository.SetPlexEtag(result.Item, incoming.Etag); + if (result.IsAdded) { await _searchIndex.AddItems(_searchRepository, new List { result.Item }); @@ -176,40 +189,37 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan } } - if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any()) - { - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); + string localPath = _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + incoming.MediaVersions.Head().MediaFiles.Head().Path, + false); - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath); + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); + Either refreshResult = + await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath); - await refreshResult.Match( - async _ => + await refreshResult.Match( + async _ => + { + foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id)) { - foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id)) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated }); - } + await _searchIndex.UpdateItems( + _searchRepository, + new List { updated }); + } - await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion); - }, - error => - { - _logger.LogWarning( - "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", - "Statistics", - localPath, - error.Value); - - return Task.CompletedTask; - }); - } + await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion); + }, + error => + { + _logger.LogWarning( + "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", + "Statistics", + localPath, + error.Value); + + return Task.CompletedTask; + }); return result; } @@ -224,188 +234,207 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan PlexMovie existing = result.Item; MovieMetadata existingMetadata = existing.MovieMetadata.Head(); - if (result.IsAdded || incoming.MovieMetadata.Head().DateUpdated > existingMetadata.DateUpdated) - { - _logger.LogDebug( - "Refreshing {Attribute} from {Path}", - "Plex Metadata", - existing.MediaVersions.Head().MediaFiles.Head().Path); - - Either maybeMetadata = - await _plexServerApiClient.GetMovieMetadata( - library, - incoming.Key.Split("/").Last(), - connection, - token); - - await maybeMetadata.Match( - async fullMetadata => + _logger.LogDebug( + "Refreshing {Attribute} for {Title}", + "Plex Metadata", + existing.MovieMetadata.Head().Title); + + Either maybeMetadata = + await _plexServerApiClient.GetMovieMetadata( + library, + incoming.Key.Split("/").Last(), + connection, + token); + + await maybeMetadata.Match( + async fullMetadata => + { + if (existingMetadata.MetadataKind != MetadataKind.External) { - if (existingMetadata.MetadataKind != MetadataKind.External) - { - existingMetadata.MetadataKind = MetadataKind.External; - await _metadataRepository.MarkAsExternal(existingMetadata); - } + existingMetadata.MetadataKind = MetadataKind.External; + await _metadataRepository.MarkAsExternal(existingMetadata); + } - if (existingMetadata.ContentRating != fullMetadata.ContentRating) + if (existingMetadata.ContentRating != fullMetadata.ContentRating) + { + existingMetadata.ContentRating = fullMetadata.ContentRating; + await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating); + result.IsUpdated = true; + } + + foreach (Genre genre in existingMetadata.Genres + .Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Remove(genre); + if (await _metadataRepository.RemoveGenre(genre)) { - existingMetadata.ContentRating = fullMetadata.ContentRating; - await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating); result.IsUpdated = true; } + } - foreach (Genre genre in existingMetadata.Genres - .Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Genre genre in fullMetadata.Genres + .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Genres.Add(genre); + if (await _movieRepository.AddGenre(existingMetadata, genre)) { - existingMetadata.Genres.Remove(genre); - if (await _metadataRepository.RemoveGenre(genre)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Genre genre in fullMetadata.Genres - .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Studio studio in existingMetadata.Studios + .Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name)) + .ToList()) + { + existingMetadata.Studios.Remove(studio); + if (await _metadataRepository.RemoveStudio(studio)) { - existingMetadata.Genres.Add(genre); - if (await _movieRepository.AddGenre(existingMetadata, genre)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Studio studio in existingMetadata.Studios - .Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name)) - .ToList()) + foreach (Studio studio in fullMetadata.Studios + .Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name)) + .ToList()) + { + existingMetadata.Studios.Add(studio); + if (await _movieRepository.AddStudio(existingMetadata, studio)) { - existingMetadata.Studios.Remove(studio); - if (await _metadataRepository.RemoveStudio(studio)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Studio studio in fullMetadata.Studios - .Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name)) - .ToList()) + foreach (Actor actor in existingMetadata.Actors + .Filter( + a => fullMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + existingMetadata.Actors.Remove(actor); + if (await _metadataRepository.RemoveActor(actor)) { - existingMetadata.Studios.Add(studio); - if (await _movieRepository.AddStudio(existingMetadata, studio)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Actor actor in existingMetadata.Actors - .Filter( - a => fullMetadata.Actors.All( - a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) - .ToList()) + foreach (Actor actor in fullMetadata.Actors + .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + existingMetadata.Actors.Add(actor); + if (await _movieRepository.AddActor(existingMetadata, actor)) { - existingMetadata.Actors.Remove(actor); - if (await _metadataRepository.RemoveActor(actor)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Actor actor in fullMetadata.Actors - .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) - .ToList()) + foreach (Director director in existingMetadata.Directors + .Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Directors.Remove(director); + if (await _metadataRepository.RemoveDirector(director)) { - existingMetadata.Actors.Add(actor); - if (await _movieRepository.AddActor(existingMetadata, actor)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Director director in existingMetadata.Directors - .Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Director director in fullMetadata.Directors + .Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Directors.Add(director); + if (await _movieRepository.AddDirector(existingMetadata, director)) { - existingMetadata.Directors.Remove(director); - if (await _metadataRepository.RemoveDirector(director)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Director director in fullMetadata.Directors - .Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Writer writer in existingMetadata.Writers + .Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Writers.Remove(writer); + if (await _metadataRepository.RemoveWriter(writer)) { - existingMetadata.Directors.Add(director); - if (await _movieRepository.AddDirector(existingMetadata, director)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Writer writer in existingMetadata.Writers - .Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Writer writer in fullMetadata.Writers + .Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Writers.Add(writer); + if (await _movieRepository.AddWriter(existingMetadata, writer)) { - existingMetadata.Writers.Remove(writer); - if (await _metadataRepository.RemoveWriter(writer)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (Writer writer in fullMetadata.Writers - .Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (MetadataGuid guid in existingMetadata.Guids + .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Remove(guid); + if (await _metadataRepository.RemoveGuid(guid)) { - existingMetadata.Writers.Add(writer); - if (await _movieRepository.AddWriter(existingMetadata, writer)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (MetadataGuid guid in existingMetadata.Guids - .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) + foreach (MetadataGuid guid in fullMetadata.Guids + .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Add(guid); + if (await _metadataRepository.AddGuid(existingMetadata, guid)) { - existingMetadata.Guids.Remove(guid); - if (await _metadataRepository.RemoveGuid(guid)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - foreach (MetadataGuid guid in fullMetadata.Guids - .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) + foreach (Tag tag in existingMetadata.Tags + .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Remove(tag); + if (await _metadataRepository.RemoveTag(tag)) { - existingMetadata.Guids.Add(guid); - if (await _metadataRepository.AddGuid(existingMetadata, guid)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - if (fullMetadata.SortTitle != existingMetadata.SortTitle) + foreach (Tag tag in fullMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Add(tag); + if (await _movieRepository.AddTag(existingMetadata, tag)) { - existingMetadata.SortTitle = fullMetadata.SortTitle; - if (await _movieRepository.UpdateSortTitle(existingMetadata)) - { - result.IsUpdated = true; - } + result.IsUpdated = true; } + } - if (result.IsUpdated) + if (fullMetadata.SortTitle != existingMetadata.SortTitle) + { + existingMetadata.SortTitle = fullMetadata.SortTitle; + if (await _movieRepository.UpdateSortTitle(existingMetadata)) { - await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); + result.IsUpdated = true; } - }, - _ => Task.CompletedTask); + } - // TODO: update other metadata? - } + if (result.IsUpdated) + { + await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); + } + }, + _ => Task.CompletedTask); + + // TODO: update other metadata? return result; } @@ -417,19 +446,12 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan { try { - PlexMovie existing = result.Item; - MediaVersion existingVersion = existing.MediaVersions.Head(); - MediaVersion incomingVersion = incoming.MediaVersions.Head(); - - if (result.IsAdded || incomingVersion.DateUpdated > existingVersion.DateUpdated) - { - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); + string localPath = _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + incoming.MediaVersions.Head().MediaFiles.Head().Path, + false); - await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false); - } + await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false); return result; } @@ -447,12 +469,9 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); - if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) - { - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); - await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); - } + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); + await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); return result; } diff --git a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs index 3b5ed2070..f1dc804a6 100644 --- a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs @@ -95,6 +95,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL ffmpegPath, ffprobePath); + await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag); + if (result.IsAdded) { await _searchIndex.AddItems(_searchRepository, new List { result.Item }); @@ -147,7 +149,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL PlexShow existing = result.Item; ShowMetadata existingMetadata = existing.ShowMetadata.Head(); - if (result.IsAdded || incoming.ShowMetadata.Head().DateUpdated > existingMetadata.DateUpdated) + if (existing.Etag != incoming.Etag) { Either maybeMetadata = await _plexServerApiClient.GetShowMetadata( @@ -262,6 +264,28 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL } } + foreach (Tag tag in existingMetadata.Tags + .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Remove(tag); + if (await _metadataRepository.RemoveTag(tag)) + { + result.IsUpdated = true; + } + } + + foreach (Tag tag in fullMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Add(tag); + if (await _televisionRepository.AddTag(existingMetadata, tag)) + { + result.IsUpdated = true; + } + } + if (result.IsUpdated) { await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); @@ -281,7 +305,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL ShowMetadata existingMetadata = existing.ShowMetadata.Head(); ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); - if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + if (existing.Etag != incoming.Etag) { await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); @@ -330,7 +354,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL ffmpegPath, ffprobePath); + await _televisionRepository.SetPlexEtag(season, incoming.Etag); + season.Show = show; + await _searchIndex.AddItems(_searchRepository, new List { season }); }, error => @@ -366,7 +393,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL SeasonMetadata existingMetadata = existing.SeasonMetadata.Head(); SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head(); - if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + if (existing.Etag != incoming.Etag) { foreach (MetadataGuid guid in existingMetadata.Guids .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) @@ -384,6 +411,22 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL await _metadataRepository.AddGuid(existingMetadata, guid); } + foreach (Tag tag in existingMetadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Remove(tag); + await _metadataRepository.RemoveTag(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Add(tag); + await _televisionRepository.AddTag(existingMetadata, tag); + } + await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); } @@ -400,6 +443,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL string ffmpegPath, string ffprobePath) { + List existingEpisodes = await _televisionRepository.GetExistingPlexEpisodes(library, season); + Either> entries = await _plexServerApiClient.GetSeasonEpisodes( library, season, @@ -431,6 +476,17 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL foreach (PlexEpisode incoming in validEpisodes) { + Option maybeExisting = existingEpisodes.Find(ie => ie.Key == incoming.Key); + if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == incoming.Etag) + { + // _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key); + continue; + } + + // _logger.LogDebug( + // "UPDATE: Etag has changed for episode {Episode}", + // $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}"); + incoming.SeasonId = season.Id; // TODO: figure out how to rebuild playlists @@ -453,6 +509,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL await maybeEpisode.Match( async result => { + await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag); + if (result.IsAdded) { await _searchIndex.AddItems(_searchRepository, new List { result.Item }); @@ -555,7 +613,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL } } - if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any()) + if (existing.Etag != incoming.Etag) { string localPath = _plexPathReplacementService.GetReplacementPlexPath( pathReplacements, @@ -607,6 +665,22 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL existingMetadata.Guids.Add(guid); await _metadataRepository.AddGuid(existingMetadata, guid); } + + foreach (Tag tag in existingMetadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Remove(tag); + await _metadataRepository.RemoveTag(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Add(tag); + await _televisionRepository.AddTag(existingMetadata, tag); + } } existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio; @@ -640,10 +714,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL try { PlexEpisode existing = result.Item; - MediaVersion existingVersion = existing.MediaVersions.Head(); - MediaVersion incomingVersion = incoming.MediaVersions.Head(); - if (result.IsAdded || incomingVersion.DateUpdated > existingVersion.DateUpdated) + if (existing.Etag != incoming.Etag) { string localPath = _plexPathReplacementService.GetReplacementPlexPath( pathReplacements, @@ -673,7 +745,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL if (maybeExistingMetadata.IsSome) { EpisodeMetadata existingMetadata = maybeExistingMetadata.ValueUnsafe(); - if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) + if (existing.Etag != incoming.Etag) { await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail); await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index 29340bcd9..a58f2b332 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -5,6 +5,7 @@ using ErsatzTV.Core.Emby; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Plex; using LanguageExt.UnsafeValueAccess; using Microsoft.EntityFrameworkCore; @@ -240,6 +241,19 @@ public class MovieRepository : IMovieRepository .Map(result => result > 0); } + public async Task> GetExistingPlexMovies(PlexLibrary library) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT Key, Etag FROM PlexMovie + INNER JOIN Movie M on PlexMovie.Id = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { LibraryId = library.Id }) + .Map(result => result.ToList()); + } + public async Task> RemoveMissingPlexMovies(PlexLibrary library, List movieKeys) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -812,6 +826,14 @@ public class MovieRepository : IMovieRepository new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default); } + public async Task SetPlexEtag(PlexMovie movie, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, movie.Id }).Map(_ => Unit.Default); + } + private static async Task>> AddMovie( TvContext dbContext, int libraryPathId, @@ -854,10 +876,18 @@ public class MovieRepository : IMovieRepository { try { + // blank out etag for initial save in case stats/metadata/etc updates fail + string etag = item.Etag; + item.Etag = string.Empty; + item.LibraryPathId = library.Paths.Head().Id; await context.PlexMovies.AddAsync(item); await context.SaveChangesAsync(); + + // restore etag + item.Etag = etag; + await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await context.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); return new MediaItemScanResult(item) { IsAdded = true }; diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index 944675323..cf03a4a4e 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -3,6 +3,7 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Metadata; +using ErsatzTV.Core.Plex; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data.Repositories; @@ -478,6 +479,8 @@ public class TelevisionRepository : ITelevisionRepository .ThenInclude(sm => sm.Artwork) .Include(i => i.SeasonMetadata) .ThenInclude(sm => sm.Guids) + .Include(i => i.SeasonMetadata) + .ThenInclude(sm => sm.Tags) .Include(s => s.LibraryPath) .ThenInclude(l => l.Library) .Include(s => s.TraktListItems) @@ -598,6 +601,45 @@ public class TelevisionRepository : ITelevisionRepository new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default); } + public async Task SetPlexEtag(PlexShow show, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE PlexShow SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, show.Id }).Map(_ => Unit.Default); + } + + public async Task SetPlexEtag(PlexSeason season, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, season.Id }).Map(_ => Unit.Default); + } + + public async Task SetPlexEtag(PlexEpisode episode, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE PlexEpisode SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, episode.Id }).Map(_ => Unit.Default); + } + + public async Task> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT PlexEpisode.Key, PlexEpisode.Etag FROM PlexEpisode + INNER JOIN Episode E on PlexEpisode.Id = E.Id + INNER JOIN MediaItem MI on E.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id + INNER JOIN Season S2 on E.SeasonId = S2.Id + INNER JOIN PlexSeason PS on S2.Id = PS.Id + WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key", + new { LibraryId = library.Id, season.Key }) + .Map(result => result.ToList()); + } + public async Task> GetShowItems(int showId) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -648,12 +690,27 @@ public class TelevisionRepository : ITelevisionRepository new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0); } - public async Task AddTag(ShowMetadata metadata, Tag tag) + public async Task AddTag(Metadata metadata, Tag tag) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.Connection.ExecuteAsync( - "INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)", - new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0); + + switch (metadata) + { + case ShowMetadata: + return await dbContext.Connection.ExecuteAsync( + "INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)", + new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0); + case SeasonMetadata: + return await dbContext.Connection.ExecuteAsync( + "INSERT INTO Tag (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)", + new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0); + case EpisodeMetadata: + return await dbContext.Connection.ExecuteAsync( + "INSERT INTO Tag (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)", + new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0); + default: + return false; + } } public async Task AddStudio(ShowMetadata metadata, Studio studio) @@ -840,10 +897,18 @@ public class TelevisionRepository : ITelevisionRepository { try { + // blank out etag for initial save in case stats/metadata/etc updates fail + string etag = item.Etag; + item.Etag = string.Empty; + item.LibraryPathId = library.Paths.Head().Id; await dbContext.PlexShows.AddAsync(item); await dbContext.SaveChangesAsync(); + + // restore etag + item.Etag = etag; + await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); return new MediaItemScanResult(item) { IsAdded = true }; @@ -861,10 +926,18 @@ public class TelevisionRepository : ITelevisionRepository { try { + // blank out etag for initial save in case stats/metadata/etc updates fail + string etag = item.Etag; + item.Etag = string.Empty; + item.LibraryPathId = library.Paths.Head().Id; await dbContext.PlexSeasons.AddAsync(item); await dbContext.SaveChangesAsync(); + + // restore etag + item.Etag = etag; + await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); return item; @@ -887,6 +960,10 @@ public class TelevisionRepository : ITelevisionRepository return BaseError.New("Multi-episode files are not yet supported"); } + // blank out etag for initial save in case stats/metadata/etc updates fail + string etag = item.Etag; + item.Etag = string.Empty; + item.LibraryPathId = library.Paths.Head().Id; foreach (EpisodeMetadata metadata in item.EpisodeMetadata) { @@ -900,6 +977,10 @@ public class TelevisionRepository : ITelevisionRepository await dbContext.PlexEpisodes.AddAsync(item); await dbContext.SaveChangesAsync(); + + // restore etag + item.Etag = etag; + await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item).Reference(e => e.Season).LoadAsync(); diff --git a/ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.Designer.cs new file mode 100644 index 000000000..4ccbdb33a --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.Designer.cs @@ -0,0 +1,4079 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20220421014846_Add_PlexEtags")] + partial class Add_PlexEtags + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.4"); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ArtworkId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("OtherVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SongMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("ArtworkId") + .IsUnique(); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("OtherVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.HasIndex("SongMetadataId"); + + b.ToTable("Actor", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ArtistMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistId") + .HasColumnType("INTEGER"); + + b.Property("Biography") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("Disambiguation") + .HasColumnType("TEXT"); + + b.Property("Formed") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistId"); + + b.ToTable("ArtistMetadata", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ArtworkKind") + .HasColumnType("INTEGER"); + + b.Property("BlurHash43") + .HasColumnType("TEXT"); + + b.Property("BlurHash54") + .HasColumnType("TEXT"); + + b.Property("BlurHash64") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("OtherVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SongMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("OtherVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.HasIndex("SongMetadataId"); + + b.ToTable("Artwork", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Categories") + .HasColumnType("TEXT"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("FallbackFillerId") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ErsatzTV"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("PreferredAudioLanguageCode") + .HasColumnType("TEXT"); + + b.Property("PreferredSubtitleLanguageCode") + .HasColumnType("TEXT"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.Property("WatermarkId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("FallbackFillerId"); + + b.HasIndex("Number") + .IsUnique(); + + b.HasIndex("WatermarkId"); + + b.ToTable("Channel", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("FrequencyMinutes") + .HasColumnType("INTEGER"); + + b.Property("HorizontalMarginPercent") + .HasColumnType("INTEGER"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("ImageSource") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Opacity") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("VerticalMarginPercent") + .HasColumnType("INTEGER"); + + b.Property("WidthPercent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ChannelWatermark", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Director", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.ToTable("Director", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("EmbyMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EmbyMediaSourceId"); + + b.ToTable("EmbyConnection", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyPathReplacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EmbyMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("EmbyPath") + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmbyMediaSourceId"); + + b.ToTable("EmbyPathReplacement", (string)null); + }); + + 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("EpisodeNumber") + .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", (string)null); + }); + + 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("AudioFormat") + .HasColumnType("INTEGER"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("DeinterlaceVideo") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("HardwareAcceleration") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeFramerate") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("NormalizeLoudness") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("VaapiDevice") + .HasColumnType("TEXT"); + + b.Property("VaapiDriver") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoFormat") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfile", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Filler.FillerPreset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FillerKind") + .HasColumnType("INTEGER"); + + b.Property("FillerMode") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("MultiCollectionId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PadToNearestMinute") + .HasColumnType("INTEGER"); + + b.Property("SmartCollectionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("MultiCollectionId"); + + b.HasIndex("SmartCollectionId"); + + b.ToTable("FillerPreset", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OtherVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SongMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("OtherVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.HasIndex("SongMetadataId"); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("JellyfinMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JellyfinMediaSourceId"); + + b.ToTable("JellyfinConnection", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinPathReplacement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JellyfinMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("JellyfinPath") + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JellyfinMediaSourceId"); + + b.ToTable("JellyfinPathReplacement", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LanguageCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EnglishName") + .HasColumnType("TEXT"); + + b.Property("FrenchName") + .HasColumnType("TEXT"); + + b.Property("ThreeCode1") + .HasColumnType("TEXT"); + + b.Property("ThreeCode2") + .HasColumnType("TEXT"); + + b.Property("TwoCode") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LanguageCode", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("LibraryPathId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryPathId"); + + b.ToTable("LibraryFolder", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScan") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryPath", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaChapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("MediaVersionId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaVersionId"); + + b.ToTable("MediaChapter", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryPathId") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryPathId"); + + b.ToTable("MediaItem", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSource", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachedPic") + .HasColumnType("INTEGER"); + + b.Property("BitsPerRawSample") + .HasColumnType("INTEGER"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Default") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Forced") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("MediaStreamKind") + .HasColumnType("INTEGER"); + + b.Property("MediaVersionId") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaVersionId"); + + b.ToTable("MediaStream", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OtherVideoId") + .HasColumnType("INTEGER"); + + b.Property("RFrameRate") + .HasColumnType("TEXT"); + + b.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b.Property("SongId") + .HasColumnType("INTEGER"); + + b.Property("VideoScanKind") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("MusicVideoId"); + + b.HasIndex("OtherVideoId"); + + b.HasIndex("SongId"); + + b.ToTable("MediaVersion", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Guid") + .HasColumnType("TEXT"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("OtherVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SongMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("OtherVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.HasIndex("SongMetadataId"); + + b.ToTable("MetadataGuid", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.ToTable("Mood"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasColumnType("TEXT"); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MultiCollection", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionItem", b => + { + b.Property("MultiCollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("PlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("ScheduleAsGroup") + .HasColumnType("INTEGER"); + + b.HasKey("MultiCollectionId", "CollectionId"); + + b.HasIndex("CollectionId"); + + b.ToTable("MultiCollectionItem", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionSmartItem", b => + { + b.Property("MultiCollectionId") + .HasColumnType("INTEGER"); + + b.Property("SmartCollectionId") + .HasColumnType("INTEGER"); + + b.Property("PlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("ScheduleAsGroup") + .HasColumnType("INTEGER"); + + b.HasKey("MultiCollectionId", "SmartCollectionId"); + + b.HasIndex("SmartCollectionId"); + + b.ToTable("MultiCollectionSmartItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoId") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Plot") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MusicVideoId"); + + b.ToTable("MusicVideoMetadata", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", 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("OtherVideoId") + .HasColumnType("INTEGER"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OtherVideoId"); + + b.ToTable("OtherVideoMetadata", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("DailyRebuildTime") + .HasColumnType("TEXT"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playout", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("CustomTitle") + .HasColumnType("TEXT"); + + b.Property("FillerKind") + .HasColumnType("INTEGER"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("GuideFinish") + .HasColumnType("TEXT"); + + b.Property("GuideGroup") + .HasColumnType("INTEGER"); + + b.Property("InPoint") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("OutPoint") + .HasColumnType("TEXT"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("WatermarkId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.HasIndex("WatermarkId"); + + b.ToTable("PlayoutItem", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnchorDate") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("MultiCollectionId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SmartCollectionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("MultiCollectionId"); + + b.HasIndex("PlayoutId"); + + b.HasIndex("ProgramScheduleId"); + + b.HasIndex("SmartCollectionId"); + + b.ToTable("PlayoutProgramScheduleAnchor", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("KeepMultiPartEpisodesTogether") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ShuffleScheduleItems") + .HasColumnType("INTEGER"); + + b.Property("TreatCollectionsAsShows") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedule", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CollectionId") + .HasColumnType("INTEGER"); + + b.Property("CollectionType") + .HasColumnType("INTEGER"); + + b.Property("CustomTitle") + .HasColumnType("TEXT"); + + b.Property("FallbackFillerId") + .HasColumnType("INTEGER"); + + b.Property("GuideMode") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("MidRollFillerId") + .HasColumnType("INTEGER"); + + b.Property("MultiCollectionId") + .HasColumnType("INTEGER"); + + b.Property("PlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("PostRollFillerId") + .HasColumnType("INTEGER"); + + b.Property("PreRollFillerId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SmartCollectionId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TailFillerId") + .HasColumnType("INTEGER"); + + b.Property("WatermarkId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.HasIndex("FallbackFillerId"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("MidRollFillerId"); + + b.HasIndex("MultiCollectionId"); + + b.HasIndex("PostRollFillerId"); + + b.HasIndex("PreRollFillerId"); + + b.HasIndex("ProgramScheduleId"); + + b.HasIndex("SmartCollectionId"); + + b.HasIndex("TailFillerId"); + + b.HasIndex("WatermarkId"); + + b.ToTable("ProgramScheduleItem", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasColumnType("TEXT"); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SmartCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Query") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SmartCollection", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SongMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtist") + .HasColumnType("TEXT"); + + b.Property("Artist") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("MetadataKind") + .HasColumnType("INTEGER"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SongId") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Track") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.ToTable("SongMetadata", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OtherVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SongMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("OtherVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.HasIndex("SongMetadataId"); + + b.ToTable("Studio", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Style", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.ToTable("Style"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Subtitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("Default") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Forced") + .HasColumnType("INTEGER"); + + b.Property("IsExtracted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("OtherVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("SDH") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SongMetadataId") + .HasColumnType("INTEGER"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("SubtitleKind") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("OtherVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.HasIndex("SongMetadataId"); + + b.ToTable("Subtitle", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArtistMetadataId") + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MusicVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OtherVideoMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SeasonMetadataId") + .HasColumnType("INTEGER"); + + b.Property("ShowMetadataId") + .HasColumnType("INTEGER"); + + b.Property("SongMetadataId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ArtistMetadataId"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.HasIndex("MusicVideoMetadataId"); + + b.HasIndex("OtherVideoMetadataId"); + + b.HasIndex("SeasonMetadataId"); + + b.HasIndex("ShowMetadataId"); + + b.HasIndex("SongMetadataId"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("List") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TraktId") + .HasColumnType("INTEGER"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TraktList", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Episode") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TraktId") + .HasColumnType("INTEGER"); + + b.Property("TraktListId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("TraktListId"); + + b.ToTable("TraktListItem", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItemGuid", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Guid") + .HasColumnType("TEXT"); + + b.Property("TraktListItemId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TraktListItemId"); + + b.ToTable("TraktListItemGuid", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Writer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EpisodeMetadataId") + .HasColumnType("INTEGER"); + + b.Property("MovieMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeMetadataId"); + + b.HasIndex("MovieMetadataId"); + + b.ToTable("Writer", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Artist", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("EmbyLibrary", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("OperatingSystem") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("EmbyMediaSource", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasIndex("SeasonId"); + + b.ToTable("Episode", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ShouldSyncItems") + .HasColumnType("INTEGER"); + + b.ToTable("JellyfinLibrary", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("OperatingSystem") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinMediaSource", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Library"); + + b.ToTable("LocalLibrary", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.ToTable("LocalMediaSource", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Movie", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideo", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.Property("ArtistId") + .HasColumnType("INTEGER"); + + b.HasIndex("ArtistId"); + + b.ToTable("MusicVideo", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("OtherVideo", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("TEXT"); + + b.Property("PlatformVersion") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("ServerName") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSource", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.Property("TailMode") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleDurationItem", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItem", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItem", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItem", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Show", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Song", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); + + b.ToTable("Song", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyEpisode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyEpisode", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyMovie", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbySeason", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyShow", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("EmbyShow", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinEpisode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinEpisode", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinMovie", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinSeason", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinSeason", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinShow", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.ToTable("JellyfinShow", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexEpisode", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexEpisode", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexMovie", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexSeason", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Season"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexSeason", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexShow", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.Show"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.ToTable("PlexShow", (string)null); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Actors") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.Artwork", "Artwork") + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Actor", "ArtworkId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Actors") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Actors") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Actors") + .HasForeignKey("MusicVideoMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null) + .WithMany("Actors") + .HasForeignKey("OtherVideoMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Actors") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Actors") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null) + .WithMany("Actors") + .HasForeignKey("SongMetadataId"); + + b.Navigation("Artwork"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ArtistMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist") + .WithMany("ArtistMetadata") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Artwork") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Channel", null) + .WithMany("Artwork") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Artwork") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Artwork") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Artwork") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null) + .WithMany("Artwork") + .HasForeignKey("OtherVideoMetadataId") + .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); + + b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null) + .WithMany("Artwork") + .HasForeignKey("SongMetadataId") + .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.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "FallbackFiller") + .WithMany() + .HasForeignKey("FallbackFillerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark") + .WithMany() + .HasForeignKey("WatermarkId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FFmpegProfile"); + + b.Navigation("FallbackFiller"); + + b.Navigation("Watermark"); + }); + + 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.Director", b => + { + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Directors") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Directors") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.EmbyMediaSource", "EmbyMediaSource") + .WithMany("Connections") + .HasForeignKey("EmbyMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmbyMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyPathReplacement", b => + { + b.HasOne("ErsatzTV.Core.Domain.EmbyMediaSource", "EmbyMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("EmbyMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmbyMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", "Episode") + .WithMany("EpisodeMetadata") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Episode"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Filler.FillerPreset", 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.MultiCollection", "MultiCollection") + .WithMany() + .HasForeignKey("MultiCollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection") + .WithMany() + .HasForeignKey("SmartCollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Collection"); + + b.Navigation("MediaItem"); + + b.Navigation("MultiCollection"); + + b.Navigation("SmartCollection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Genre", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Genres") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Genres") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Genres") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Genres") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null) + .WithMany("Genres") + .HasForeignKey("OtherVideoMetadataId"); + + 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); + + b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null) + .WithMany("Genres") + .HasForeignKey("SongMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.JellyfinMediaSource", "JellyfinMediaSource") + .WithMany("Connections") + .HasForeignKey("JellyfinMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JellyfinMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinPathReplacement", b => + { + b.HasOne("ErsatzTV.Core.Domain.JellyfinMediaSource", "JellyfinMediaSource") + .WithMany("PathReplacements") + .HasForeignKey("JellyfinMediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JellyfinMediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "MediaSource") + .WithMany("Libraries") + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryFolder", b => + { + b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath") + .WithMany("LibraryFolders") + .HasForeignKey("LibraryPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", "Library") + .WithMany("Paths") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaChapter", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") + .WithMany("Chapters") + .HasForeignKey("MediaVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") + .WithMany("MediaFiles") + .HasForeignKey("MediaVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath") + .WithMany("MediaItems") + .HasForeignKey("LibraryPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LibraryPath"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaStream", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion") + .WithMany("Streams") + .HasForeignKey("MediaVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaVersion"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithMany("MediaVersions") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithMany("MediaVersions") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideo", null) + .WithMany("MediaVersions") + .HasForeignKey("MusicVideoId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideo", null) + .WithMany("MediaVersions") + .HasForeignKey("OtherVideoId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Song", null) + .WithMany("MediaVersions") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Guids") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Guids") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Guids") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Guids") + .HasForeignKey("MusicVideoMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null) + .WithMany("Guids") + .HasForeignKey("OtherVideoMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Guids") + .HasForeignKey("SeasonMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Guids") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null) + .WithMany("Guids") + .HasForeignKey("SongMetadataId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Moods") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", "Movie") + .WithMany("MovieMetadata") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection") + .WithMany("MultiCollectionItems") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection") + .WithMany("MultiCollectionItems") + .HasForeignKey("MultiCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("MultiCollection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionSmartItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection") + .WithMany("MultiCollectionSmartItems") + .HasForeignKey("MultiCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection") + .WithMany("MultiCollectionSmartItems") + .HasForeignKey("SmartCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MultiCollection"); + + b.Navigation("SmartCollection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo") + .WithMany("MusicVideoMetadata") + .HasForeignKey("MusicVideoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MusicVideo"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.OtherVideo", "OtherVideo") + .WithMany("OtherVideoMetadata") + .HasForeignKey("OtherVideoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OtherVideo"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("DurationFinish") + .HasColumnType("TEXT"); + + b1.Property("InDurationFiller") + .HasColumnType("INTEGER"); + + b1.Property("InFlood") + .HasColumnType("INTEGER"); + + b1.Property("MultipleRemaining") + .HasColumnType("INTEGER"); + + b1.Property("NextGuideGroup") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.ToTable("PlayoutAnchor", (string)null); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "ScheduleItemsEnumeratorState", b2 => + { + b2.Property("PlayoutAnchorPlayoutId") + .HasColumnType("INTEGER"); + + b2.Property("Index") + .HasColumnType("INTEGER"); + + b2.Property("Seed") + .HasColumnType("INTEGER"); + + b2.HasKey("PlayoutAnchorPlayoutId"); + + b2.ToTable("ScheduleItemsEnumeratorState", (string)null); + + b2.WithOwner() + .HasForeignKey("PlayoutAnchorPlayoutId"); + }); + + b1.Navigation("ScheduleItemsEnumeratorState"); + }); + + 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.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark") + .WithMany() + .HasForeignKey("WatermarkId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + + b.Navigation("Watermark"); + }); + + 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.MultiCollection", "MultiCollection") + .WithMany() + .HasForeignKey("MultiCollectionId") + .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.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection") + .WithMany() + .HasForeignKey("SmartCollectionId"); + + 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("CollectionEnumeratorState", (string)null); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorId"); + }); + + b.Navigation("Collection"); + + b.Navigation("EnumeratorState"); + + b.Navigation("MediaItem"); + + b.Navigation("MultiCollection"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + + b.Navigation("SmartCollection"); + }); + + 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.Filler.FillerPreset", "FallbackFiller") + .WithMany() + .HasForeignKey("FallbackFillerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "MidRollFiller") + .WithMany() + .HasForeignKey("MidRollFillerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection") + .WithMany() + .HasForeignKey("MultiCollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PostRollFiller") + .WithMany() + .HasForeignKey("PostRollFillerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PreRollFiller") + .WithMany() + .HasForeignKey("PreRollFillerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection") + .WithMany() + .HasForeignKey("SmartCollectionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "TailFiller") + .WithMany() + .HasForeignKey("TailFillerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark") + .WithMany() + .HasForeignKey("WatermarkId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Collection"); + + b.Navigation("FallbackFiller"); + + b.Navigation("MediaItem"); + + b.Navigation("MidRollFiller"); + + b.Navigation("MultiCollection"); + + b.Navigation("PostRollFiller"); + + b.Navigation("PreRollFiller"); + + b.Navigation("ProgramSchedule"); + + b.Navigation("SmartCollection"); + + b.Navigation("TailFiller"); + + b.Navigation("Watermark"); + }); + + 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.SongMetadata", b => + { + b.HasOne("ErsatzTV.Core.Domain.Song", "Song") + .WithMany("SongMetadata") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Studio", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Studios") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Studios") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Studios") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Studios") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null) + .WithMany("Studios") + .HasForeignKey("OtherVideoMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Studios") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Studios") + .HasForeignKey("ShowMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null) + .WithMany("Studios") + .HasForeignKey("SongMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Style", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Styles") + .HasForeignKey("ArtistMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Subtitle", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("OtherVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("SeasonMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("ShowMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null) + .WithMany("Subtitles") + .HasForeignKey("SongMetadataId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b => + { + b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) + .WithMany("Tags") + .HasForeignKey("ArtistMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Tags") + .HasForeignKey("EpisodeMetadataId"); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Tags") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null) + .WithMany("Tags") + .HasForeignKey("MusicVideoMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null) + .WithMany("Tags") + .HasForeignKey("OtherVideoMetadataId") + .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); + + b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null) + .WithMany("Tags") + .HasForeignKey("SongMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany("TraktListItems") + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ErsatzTV.Core.Domain.TraktList", "TraktList") + .WithMany("Items") + .HasForeignKey("TraktListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("TraktList"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItemGuid", b => + { + b.HasOne("ErsatzTV.Core.Domain.TraktListItem", "TraktListItem") + .WithMany("Guids") + .HasForeignKey("TraktListItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TraktListItem"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Writer", b => + { + b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null) + .WithMany("Writers") + .HasForeignKey("EpisodeMetadataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null) + .WithMany("Writers") + .HasForeignKey("MovieMetadataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Artist", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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.JellyfinLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.Library", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinLibrary", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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.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.Movie", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Movie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideo", b => + { + b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist") + .WithMany("MusicVideos") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.MusicVideo", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Artist"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.OtherVideo", "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.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.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.Song", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.Song", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyEpisode", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMovie", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbySeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.EmbyShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinEpisode", b => + { + b.HasOne("ErsatzTV.Core.Domain.Episode", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinEpisode", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMovie", b => + { + b.HasOne("ErsatzTV.Core.Domain.Movie", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinMovie", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinSeason", b => + { + b.HasOne("ErsatzTV.Core.Domain.Season", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinSeason", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinShow", b => + { + b.HasOne("ErsatzTV.Core.Domain.Show", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.JellyfinShow", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.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.ArtistMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Moods"); + + b.Navigation("Studios"); + + b.Navigation("Styles"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Artwork"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => + { + b.Navigation("CollectionItems"); + + b.Navigation("MultiCollectionItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Directors"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Studios"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + + b.Navigation("Writers"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => + { + b.Navigation("Paths"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => + { + b.Navigation("LibraryFolders"); + + b.Navigation("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Navigation("CollectionItems"); + + b.Navigation("TraktListItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Navigation("Libraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => + { + b.Navigation("Chapters"); + + b.Navigation("MediaFiles"); + + b.Navigation("Streams"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Directors"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Studios"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + + b.Navigation("Writers"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollection", b => + { + b.Navigation("MultiCollectionItems"); + + b.Navigation("MultiCollectionSmartItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Studios"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Studios"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Studios"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Studios"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SmartCollection", b => + { + b.Navigation("MultiCollectionSmartItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SongMetadata", b => + { + b.Navigation("Actors"); + + b.Navigation("Artwork"); + + b.Navigation("Genres"); + + b.Navigation("Guids"); + + b.Navigation("Studios"); + + b.Navigation("Subtitles"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TraktListItem", b => + { + b.Navigation("Guids"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => + { + b.Navigation("ArtistMetadata"); + + b.Navigation("MusicVideos"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b => + { + b.Navigation("EpisodeMetadata"); + + b.Navigation("MediaVersions"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("MovieMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideo", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("MusicVideoMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("OtherVideoMetadata"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("PathReplacements"); + }); + + 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.Song", b => + { + b.Navigation("MediaVersions"); + + b.Navigation("SongMetadata"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.cs b/ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.cs new file mode 100644 index 000000000..da4079d0e --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Add_PlexEtags : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Etag", + table: "PlexShow", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Etag", + table: "PlexSeason", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Etag", + table: "PlexMovie", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Etag", + table: "PlexEpisode", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Etag", + table: "PlexShow"); + + migrationBuilder.DropColumn( + name: "Etag", + table: "PlexSeason"); + + migrationBuilder.DropColumn( + name: "Etag", + table: "PlexMovie"); + + migrationBuilder.DropColumn( + name: "Etag", + table: "PlexEpisode"); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 0d9411cf1..535c74a59 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -2404,6 +2404,9 @@ namespace ErsatzTV.Infrastructure.Migrations { b.HasBaseType("ErsatzTV.Core.Domain.Episode"); + b.Property("Etag") + .HasColumnType("TEXT"); + b.Property("Key") .HasColumnType("TEXT"); @@ -2414,6 +2417,9 @@ namespace ErsatzTV.Infrastructure.Migrations { b.HasBaseType("ErsatzTV.Core.Domain.Movie"); + b.Property("Etag") + .HasColumnType("TEXT"); + b.Property("Key") .HasColumnType("TEXT"); @@ -2424,6 +2430,9 @@ namespace ErsatzTV.Infrastructure.Migrations { b.HasBaseType("ErsatzTV.Core.Domain.Season"); + b.Property("Etag") + .HasColumnType("TEXT"); + b.Property("Key") .HasColumnType("TEXT"); @@ -2434,6 +2443,9 @@ namespace ErsatzTV.Infrastructure.Migrations { b.HasBaseType("ErsatzTV.Core.Domain.Show"); + b.Property("Etag") + .HasColumnType("TEXT"); + b.Property("Key") .HasColumnType("TEXT"); diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexCollectionResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexCollectionResponse.cs new file mode 100644 index 000000000..7cc98d95b --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexCollectionResponse.cs @@ -0,0 +1,12 @@ +using System.Xml.Serialization; + +namespace ErsatzTV.Infrastructure.Plex.Models; + +public class PlexCollectionResponse +{ + [XmlAttribute("id")] + public int Id { get; set; } + + [XmlAttribute("tag")] + public string Tag { get; set; } +} diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs index c02df8fc9..04182d2c2 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs @@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models; public class PlexDirectorResponse { + [XmlAttribute("id")] + public int Id { get; set; } + [XmlAttribute("tag")] public string Tag { get; set; } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs index 9a7504b3d..2264c3bb5 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs @@ -69,4 +69,7 @@ public class PlexMetadataResponse [XmlElement("Writer")] public List Writer { get; set; } + + [XmlElement("Collection")] + public List Collection { get; set; } } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs index 12d78fd75..29ee1ef65 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs @@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models; public class PlexRoleResponse { + [XmlAttribute("id")] + public int Id { get; set; } + [XmlAttribute("tag")] public string Tag { get; set; } diff --git a/ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.mcs.cs b/ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.cs similarity index 75% rename from ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.mcs.cs rename to ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.cs index 88a72d7dc..3f8c0266e 100644 --- a/ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.mcs.cs +++ b/ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.cs @@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models; public class PlexWriterResponse { + [XmlAttribute("id")] + public int Id { get; set; } + [XmlAttribute("tag")] public string Tag { get; set; } } diff --git a/ErsatzTV.Infrastructure/Plex/PlexEtag.cs b/ErsatzTV.Infrastructure/Plex/PlexEtag.cs new file mode 100644 index 000000000..1d2615818 --- /dev/null +++ b/ErsatzTV.Infrastructure/Plex/PlexEtag.cs @@ -0,0 +1,244 @@ +using System.Security.Cryptography; +using ErsatzTV.Infrastructure.Plex.Models; + +namespace ErsatzTV.Infrastructure.Plex; + +public static class PlexEtag +{ + public static string ForMovie(PlexMetadataResponse response) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + // video key + bw.Write(response.Key); + + // video added at + bw.Write(response.AddedAt); + + // video updated at + bw.Write(response.UpdatedAt); + + foreach (PlexMediaResponse media in response.Media) + { + // media id + bw.Write((byte)FieldKey.MediaId); + bw.Write(media.Id); + + // media part id + foreach (PlexPartResponse part in media.Part) + { + bw.Write((byte)FieldKey.PartId); + bw.Write(part.Id); + } + } + + // genre ids + foreach (PlexGenreResponse genre in Optional(response.Genre).Flatten()) + { + bw.Write((byte)FieldKey.GenreTag); + bw.Write(genre.Tag); + } + + // director ids + foreach (PlexDirectorResponse director in Optional(response.Director).Flatten()) + { + bw.Write((byte)FieldKey.DirectorTag); + bw.Write(director.Tag); + } + + // writer ids + foreach (PlexWriterResponse writer in Optional(response.Writer).Flatten()) + { + bw.Write((byte)FieldKey.WriterTag); + bw.Write(writer.Tag); + } + + // collection ids + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + bw.Write((byte)FieldKey.CollectionTag); + bw.Write(collection.Tag); + } + + // role ids + foreach (PlexRoleResponse role in Optional(response.Role).Flatten()) + { + bw.Write((byte)FieldKey.RoleTag); + bw.Write(role.Tag); + } + + ms.Position = 0; + byte[] hash = SHA1.Create().ComputeHash(ms); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } + + public static string ForShow(PlexMetadataResponse response) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + // video key + bw.Write(response.Key); + + // video added at + bw.Write(response.AddedAt); + + // video updated at + bw.Write(response.UpdatedAt); + + // genre ids + foreach (PlexGenreResponse genre in Optional(response.Genre).Flatten()) + { + bw.Write((byte)FieldKey.GenreTag); + bw.Write(genre.Tag); + } + + // collection ids + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + bw.Write((byte)FieldKey.CollectionTag); + bw.Write(collection.Tag); + } + + // role ids + foreach (PlexRoleResponse role in Optional(response.Role).Flatten()) + { + bw.Write((byte)FieldKey.RoleTag); + bw.Write(role.Tag); + } + + ms.Position = 0; + byte[] hash = SHA1.Create().ComputeHash(ms); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } + + public static string ForSeason(PlexXmlMetadataResponse response) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + // video key + bw.Write(response.Key); + + // video added at + bw.Write(response.AddedAt); + + // video updated at + bw.Write(response.UpdatedAt); + + // collection ids + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + bw.Write((byte)FieldKey.CollectionTag); + bw.Write(collection.Tag); + } + + // thumb + if (!string.IsNullOrWhiteSpace(response.Thumb)) + { + bw.Write(response.Thumb); + } + + // art + if (!string.IsNullOrWhiteSpace(response.Art)) + { + bw.Write(response.Art); + } + + ms.Position = 0; + byte[] hash = SHA1.Create().ComputeHash(ms); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } + + public static string ForEpisode(PlexXmlMetadataResponse response) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + // video key + bw.Write(response.Key); + + // video added at + bw.Write(response.AddedAt); + + // video updated at + bw.Write(response.UpdatedAt); + + foreach (PlexMediaResponse media in response.Media) + { + // media id + bw.Write((byte)FieldKey.MediaId); + bw.Write(media.Id); + + // media part id + foreach (PlexXmlPartResponse part in media.Part) + { + bw.Write((byte)FieldKey.PartId); + bw.Write(part.Id); + + // media part id + foreach (PlexStreamResponse stream in part.Stream) + { + bw.Write((byte)FieldKey.StreamId); + bw.Write(stream.Id); + } + } + } + + // genre ids + foreach (PlexGenreResponse genre in Optional(response.Genre).Flatten()) + { + bw.Write((byte)FieldKey.GenreTag); + bw.Write(genre.Tag); + } + + // director ids + foreach (PlexDirectorResponse director in Optional(response.Director).Flatten()) + { + bw.Write((byte)FieldKey.DirectorTag); + bw.Write(director.Tag); + } + + // writer ids + foreach (PlexWriterResponse writer in Optional(response.Writer).Flatten()) + { + bw.Write((byte)FieldKey.WriterTag); + bw.Write(writer.Tag); + } + + // collection ids + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + bw.Write((byte)FieldKey.CollectionTag); + bw.Write(collection.Tag); + } + + // role ids + foreach (PlexRoleResponse role in Optional(response.Role).Flatten()) + { + bw.Write((byte)FieldKey.RoleTag); + bw.Write(role.Tag); + } + + ms.Position = 0; + byte[] hash = SHA1.Create().ComputeHash(ms); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } + + private enum FieldKey : byte + { + MediaId = 0, + PartId = 1, + StreamId = 2, + + GenreTag = 10, + DirectorTag = 11, + WriterTag = 12, + CollectionTag = 13, + RoleTag = 14, + + Thumb = 20, + Art = 21 + } +} diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index a0e6d35a2..2e3418c6f 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -158,9 +158,7 @@ public class PlexServerApiClient : IPlexServerApiClient IPlexServerApi service = XmlServiceFor(connection.Uri); return await service.GetVideoMetadata(key, token.AuthToken) .Map(Optional) - .Map( - r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0) - .HeadOrNone()) + .Map(r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)) .MapT(response => ProjectToMovieMetadata(response.Metadata, library.MediaSourceId)) .Map(o => o.ToEither("Unable to locate metadata")); } @@ -202,9 +200,7 @@ public class PlexServerApiClient : IPlexServerApiClient Option maybeResponse = await service .GetVideoMetadata(key, token.AuthToken) .Map(Optional) - .Map( - r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0) - .HeadOrNone()); + .Map(r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)); return maybeResponse.Match( response => { @@ -233,9 +229,7 @@ public class PlexServerApiClient : IPlexServerApiClient Option maybeResponse = await service .GetVideoMetadata(key, token.AuthToken) .Map(Optional) - .Map( - r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0) - .HeadOrNone()); + .Map(r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)); return maybeResponse.Match( response => { @@ -348,6 +342,7 @@ public class PlexServerApiClient : IPlexServerApiClient var movie = new PlexMovie { + Etag = PlexEtag.ForMovie(response), Key = response.Key, MovieMetadata = new List { metadata }, MediaVersions = new List { version }, @@ -402,6 +397,11 @@ public class PlexServerApiClient : IPlexServerApiClient metadata.Guids = new List(); } + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + metadata.Tags.Add(new Tag { Name = collection.Tag }); + } + if (!string.IsNullOrWhiteSpace(response.Studio)) { metadata.Studios.Add(new Studio { Name = response.Studio }); @@ -523,6 +523,7 @@ public class PlexServerApiClient : IPlexServerApiClient var show = new PlexShow { Key = response.Key, + Etag = PlexEtag.ForShow(response), ShowMetadata = new List { metadata }, TraktListItems = new List() }; @@ -578,6 +579,11 @@ public class PlexServerApiClient : IPlexServerApiClient metadata.Studios.Add(new Studio { Name = response.Studio }); } + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + metadata.Tags.Add(new Tag { Name = collection.Tag }); + } + if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; @@ -628,7 +634,8 @@ public class PlexServerApiClient : IPlexServerApiClient SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), Year = response.Year, DateAdded = dateAdded, - DateUpdated = lastWriteTime + DateUpdated = lastWriteTime, + Tags = new List() }; metadata.Guids = Optional(response.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList(); @@ -644,6 +651,11 @@ public class PlexServerApiClient : IPlexServerApiClient } } + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + metadata.Tags.Add(new Tag { Name = collection.Tag }); + } + if (!string.IsNullOrWhiteSpace(response.Thumb)) { string path = $"plex/{mediaSourceId}{response.Thumb}"; @@ -677,6 +689,7 @@ public class PlexServerApiClient : IPlexServerApiClient var season = new PlexSeason { Key = response.Key, + Etag = PlexEtag.ForSeason(response), SeasonNumber = response.Index, SeasonMetadata = new List { metadata }, TraktListItems = new List() @@ -717,6 +730,7 @@ public class PlexServerApiClient : IPlexServerApiClient var episode = new PlexEpisode { Key = response.Key, + Etag = PlexEtag.ForEpisode(response), EpisodeMetadata = new List { metadata }, MediaVersions = new List { version }, TraktListItems = new List() @@ -744,7 +758,8 @@ public class PlexServerApiClient : IPlexServerApiClient Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime)) .ToList(), Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(), - Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList() + Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList(), + Tags = new List() }; if (response is PlexXmlMetadataResponse xml) @@ -772,6 +787,11 @@ public class PlexServerApiClient : IPlexServerApiClient metadata.ReleaseDate = releaseDate; } + foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten()) + { + metadata.Tags.Add(new Tag { Name = collection.Tag }); + } + if (!string.IsNullOrWhiteSpace(response.Thumb)) { string path = $"plex/{mediaSourceId}{response.Thumb}"; diff --git a/ErsatzTV.Infrastructure/Search/SearchIndex.cs b/ErsatzTV.Infrastructure/Search/SearchIndex.cs index 8d61067d0..b568cb4fa 100644 --- a/ErsatzTV.Infrastructure/Search/SearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/SearchIndex.cs @@ -567,6 +567,11 @@ public sealed class SearchIndex : ISearchIndex doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO)); } + foreach (Tag tag in metadata.Tags) + { + doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); + } + _writer.UpdateDocument(new Term(IdField, season.Id.ToString()), doc); } catch (Exception ex) diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index f04d50bd2..fa4bb690e 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -59,7 +59,7 @@ - + diff --git a/scripts/cleanup-all-code.sh b/scripts/cleanup-all-code.sh new file mode 100755 index 000000000..251bbf970 --- /dev/null +++ b/scripts/cleanup-all-code.sh @@ -0,0 +1,6 @@ +#! /usr/bin/env bash + +cd "$(git rev-parse --show-cdup)" || exit + +dotnet tool restore +dotnet jb cleanupcode ErsatzTV.sln --exclude='CHANGELOG.md;scripts/**;generated/**;ErsatzTV/client-app/**' diff --git a/scripts/cleanup-code.sh b/scripts/cleanup-code.sh index 653b80811..30ae3db1e 100755 --- a/scripts/cleanup-code.sh +++ b/scripts/cleanup-code.sh @@ -3,4 +3,5 @@ cd "$(git rev-parse --show-cdup)" || exit dotnet tool restore -dotnet jb cleanupcode ErsatzTV.sln --exclude='generated/**;ErsatzTV/client-app/**' +CHANGED=$(git status --porcelain | sed 's/^...//' | paste -sd ";" -) +dotnet jb cleanupcode ErsatzTV.sln --exclude='CHANGELOG.md;scripts/**;generated/**;ErsatzTV/client-app/**' --include="$CHANGED"