diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4bd4095..6a770c907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed +- Cleanly stop Plex library scan when service termination is requested +- Fix bug introduced with 0.5.2-beta that prevented some Plex content from being played +- Fix spammy subtitle error message + +### Changed +- No longer remove Plex movies and episodes from ErsatzTV when they do not exist on disk + - Instead, a new `unavailable` media state will be used to indicate this condition + - After updating mounts, path replacements, etc - a library scan can be used to resolve this state ## [0.5.2-beta] - 2022-04-22 ### Fixed diff --git a/ErsatzTV.Application/MediaCards/Mapper.cs b/ErsatzTV.Application/MediaCards/Mapper.cs index 20a47e9aa..442cc7d6c 100644 --- a/ErsatzTV.Application/MediaCards/Mapper.cs +++ b/ErsatzTV.Application/MediaCards/Mapper.cs @@ -61,7 +61,8 @@ internal static class Mapper EpisodeMetadata episodeMetadata, Option maybeJellyfin, Option maybeEmby, - bool isSearchResult) => + bool isSearchResult, + string localPath) => new( episodeMetadata.EpisodeId, episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc, @@ -83,7 +84,8 @@ internal static class Mapper episodeMetadata.Directors.Map(d => d.Name).ToList(), episodeMetadata.Writers.Map(w => w.Name).ToList(), episodeMetadata.Episode.State, - episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path); + episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path, + localPath); internal static MovieCardViewModel ProjectToViewModel( MovieMetadata movieMetadata, @@ -97,7 +99,9 @@ internal static class Mapper GetPoster(movieMetadata, maybeJellyfin, maybeEmby), movieMetadata.Movie.State); - internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) => + internal static MusicVideoCardViewModel ProjectToViewModel( + MusicVideoMetadata musicVideoMetadata, + string localPath) => new( musicVideoMetadata.MusicVideoId, musicVideoMetadata.Title, @@ -107,7 +111,8 @@ internal static class Mapper musicVideoMetadata.Album, GetThumbnail(musicVideoMetadata, None, None), musicVideoMetadata.MusicVideo.State, - musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path); + musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path, + localPath); internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) => new( @@ -143,7 +148,7 @@ internal static class Mapper Collection collection, Option maybeJellyfin, Option maybeEmby) => - new( + new CollectionCardResultsViewModel( collection.Name, collection.MediaItems.OfType().Map( m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with @@ -155,11 +160,14 @@ internal static class Mapper .ToList(), collection.MediaItems.OfType().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)) .ToList(), + // collection view doesn't use local paths collection.MediaItems.OfType() - .Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false)) + .Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false, string.Empty)) .ToList(), collection.MediaItems.OfType().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(), - collection.MediaItems.OfType().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head())) + // collection view doesn't use local paths + collection.MediaItems.OfType() + .Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head(), string.Empty)) .ToList(), collection.MediaItems.OfType().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head())) .ToList(), diff --git a/ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs b/ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs index 85d8bad3e..baa1890ff 100644 --- a/ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs +++ b/ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs @@ -12,7 +12,8 @@ public record MusicVideoCardViewModel string Album, string Poster, MediaItemState State, - string Path) : MediaCardViewModel( + string Path, + string LocalPath) : MediaCardViewModel( MusicVideoId, Title, Subtitle, diff --git a/ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs index 514b3d65f..76b7fe12e 100644 --- a/ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs +++ b/ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs @@ -1,14 +1,31 @@ -using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; using static ErsatzTV.Application.MediaCards.Mapper; namespace ErsatzTV.Application.MediaCards; public class GetMusicVideoCardsHandler : IRequestHandler { + private readonly IEmbyPathReplacementService _embyPathReplacementService; + private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IMusicVideoRepository _musicVideoRepository; + private readonly IPlexPathReplacementService _plexPathReplacementService; - public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) => + public GetMusicVideoCardsHandler( + IMusicVideoRepository musicVideoRepository, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService) + { _musicVideoRepository = musicVideoRepository; + _plexPathReplacementService = plexPathReplacementService; + _jellyfinPathReplacementService = jellyfinPathReplacementService; + _embyPathReplacementService = embyPathReplacementService; + } public async Task Handle( GetMusicVideoCards request, @@ -16,9 +33,21 @@ public class GetMusicVideoCardsHandler : IRequestHandler results = await _musicVideoRepository - .GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize) - .Map(list => list.Map(ProjectToViewModel).ToList()); + List musicVideos = await _musicVideoRepository + .GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize); + + var results = new List(); + + foreach (MusicVideoMetadata musicVideoMetadata in musicVideos) + { + string localPath = await musicVideoMetadata.MusicVideo.GetLocalPath( + _plexPathReplacementService, + _jellyfinPathReplacementService, + _embyPathReplacementService, + false); + + results.Add(ProjectToViewModel(musicVideoMetadata, localPath)); + } return new MusicVideoCardResultsViewModel(count, results, None); } diff --git a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs index 0f2b7949d..451506a6c 100644 --- a/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs +++ b/ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs @@ -1,4 +1,8 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Search; using static ErsatzTV.Application.MediaCards.Mapper; @@ -6,18 +10,26 @@ using static ErsatzTV.Application.MediaCards.Mapper; namespace ErsatzTV.Application.MediaCards; public class - GetTelevisionEpisodeCardsHandler : IRequestHandler + GetTelevisionEpisodeCardsHandler : IRequestHandler { + private readonly IEmbyPathReplacementService _embyPathReplacementService; + private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly ITelevisionRepository _televisionRepository; public GetTelevisionEpisodeCardsHandler( ITelevisionRepository televisionRepository, - IMediaSourceRepository mediaSourceRepository) + IMediaSourceRepository mediaSourceRepository, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService) { _televisionRepository = televisionRepository; _mediaSourceRepository = mediaSourceRepository; + _plexPathReplacementService = plexPathReplacementService; + _jellyfinPathReplacementService = jellyfinPathReplacementService; + _embyPathReplacementService = embyPathReplacementService; } public async Task Handle( @@ -32,9 +44,20 @@ public class Option maybeEmby = await _mediaSourceRepository.GetAllEmby() .Map(list => list.HeadOrNone()); - List results = await _televisionRepository - .GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize) - .Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby, false)).ToList()); + List episodes = await _televisionRepository + .GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize); + + var results = new List(); + foreach (EpisodeMetadata episodeMetadata in episodes) + { + string localPath = await episodeMetadata.Episode.GetLocalPath( + _plexPathReplacementService, + _jellyfinPathReplacementService, + _embyPathReplacementService, + false); + + results.Add(ProjectToViewModel(episodeMetadata, maybeJellyfin, maybeEmby, false, localPath)); + } return new TelevisionEpisodeCardResultsViewModel(count, results, Option.None); } diff --git a/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs b/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs index 78f5a9595..2b52bea8f 100644 --- a/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs +++ b/ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs @@ -18,7 +18,8 @@ public record TelevisionEpisodeCardViewModel List Directors, List Writers, MediaItemState State, - string Path) : MediaCardViewModel( + string Path, + string LocalPath) : MediaCardViewModel( EpisodeId, Title, $"Episode {Episode}", diff --git a/ErsatzTV.Application/Movies/Mapper.cs b/ErsatzTV.Application/Movies/Mapper.cs index d0006879f..9dcf2f3b1 100644 --- a/ErsatzTV.Application/Movies/Mapper.cs +++ b/ErsatzTV.Application/Movies/Mapper.cs @@ -11,6 +11,7 @@ internal static class Mapper { internal static MovieViewModel ProjectToViewModel( Movie movie, + string localPath, List languageCodes, Option maybeJellyfin, Option maybeEmby) @@ -32,6 +33,7 @@ internal static class Mapper metadata.Directors.Map(d => d.Name).ToList(), metadata.Writers.Map(w => w.Name).ToList(), movie.GetHeadVersion().MediaFiles.Head().Path, + localPath, movie.State) { Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby), diff --git a/ErsatzTV.Application/Movies/MovieViewModel.cs b/ErsatzTV.Application/Movies/MovieViewModel.cs index f46190f71..1a75cea08 100644 --- a/ErsatzTV.Application/Movies/MovieViewModel.cs +++ b/ErsatzTV.Application/Movies/MovieViewModel.cs @@ -17,6 +17,7 @@ public record MovieViewModel( List Directors, List Writers, string Path, + string LocalPath, MediaItemState MediaItemState) { public string Poster { get; set; } diff --git a/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs index 4b2a41ee6..320443071 100644 --- a/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs +++ b/ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs @@ -1,4 +1,8 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -10,17 +14,26 @@ namespace ErsatzTV.Application.Movies; public class GetMovieByIdHandler : IRequestHandler> { private readonly IDbContextFactory _dbContextFactory; + private readonly IEmbyPathReplacementService _embyPathReplacementService; + private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMovieRepository _movieRepository; + private readonly IPlexPathReplacementService _plexPathReplacementService; public GetMovieByIdHandler( IDbContextFactory dbContextFactory, IMovieRepository movieRepository, - IMediaSourceRepository mediaSourceRepository) + IMediaSourceRepository mediaSourceRepository, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService) { _dbContextFactory = dbContextFactory; _movieRepository = movieRepository; _mediaSourceRepository = mediaSourceRepository; + _plexPathReplacementService = plexPathReplacementService; + _jellyfinPathReplacementService = jellyfinPathReplacementService; + _embyPathReplacementService = embyPathReplacementService; } public async Task> Handle( @@ -35,9 +48,9 @@ public class GetMovieByIdHandler : IRequestHandler maybeEmby = await _mediaSourceRepository.GetAllEmby() .Map(list => list.HeadOrNone()); - Option movie = await _movieRepository.GetMovie(request.Id); + Option maybeMovie = await _movieRepository.GetMovie(request.Id); - Option maybeVersion = movie.Map(m => m.MediaVersions.HeadOrNone()).Flatten(); + Option maybeVersion = maybeMovie.Map(m => m.MediaVersions.HeadOrNone()).Flatten(); var languageCodes = new List(); foreach (MediaVersion version in maybeVersion) { @@ -49,6 +62,16 @@ public class GetMovieByIdHandler : IRequestHandler ProjectToViewModel(m, languageCodes, maybeJellyfin, maybeEmby)); + foreach (Movie movie in maybeMovie) + { + string localPath = await movie.GetLocalPath( + _plexPathReplacementService, + _jellyfinPathReplacementService, + _embyPathReplacementService, + false); + return ProjectToViewModel(movie, localPath, languageCodes, maybeJellyfin, maybeEmby); + } + + return None; } } diff --git a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs index 233100d2e..2f18e9f94 100644 --- a/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs @@ -43,19 +43,24 @@ public class public Task> Handle( ForceSynchronizePlexLibraryById request, - CancellationToken cancellationToken) => Handle(request); + CancellationToken cancellationToken) => HandleImpl(request, cancellationToken); public Task> Handle( SynchronizePlexLibraryByIdIfNeeded request, - CancellationToken cancellationToken) => Handle(request); + CancellationToken cancellationToken) => HandleImpl(request, cancellationToken); - private Task> - Handle(ISynchronizePlexLibraryById request) => - Validate(request) - .MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name)) - .Bind(v => v.ToEitherAsync()); + private async Task> + HandleImpl(ISynchronizePlexLibraryById request, CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + parameters => Synchronize(parameters, cancellationToken), + error => Task.FromResult>(error.Join())); + } - private async Task Synchronize(RequestParameters parameters) + private async Task> Synchronize( + RequestParameters parameters, + CancellationToken cancellationToken) { try { @@ -63,30 +68,36 @@ public class DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); if (parameters.ForceScan || nextScan < DateTimeOffset.Now) { - switch (parameters.Library.MediaKind) + Either result = parameters.Library.MediaKind switch { - case LibraryMediaKind.Movies: + LibraryMediaKind.Movies => await _plexMovieLibraryScanner.ScanLibrary( parameters.ConnectionParameters.ActiveConnection, parameters.ConnectionParameters.PlexServerAuthToken, parameters.Library, parameters.FFmpegPath, parameters.FFprobePath, - parameters.DeepScan); - break; - case LibraryMediaKind.Shows: + parameters.DeepScan, + cancellationToken), + LibraryMediaKind.Shows => await _plexTelevisionLibraryScanner.ScanLibrary( parameters.ConnectionParameters.ActiveConnection, parameters.ConnectionParameters.PlexServerAuthToken, parameters.Library, parameters.FFmpegPath, parameters.FFprobePath, - parameters.DeepScan); - break; + parameters.DeepScan, + cancellationToken), + _ => Unit.Default + }; + + if (result.IsRight) + { + parameters.Library.LastScan = DateTime.UtcNow; + await _libraryRepository.UpdateLastScan(parameters.Library); } - parameters.Library.LastScan = DateTime.UtcNow; - await _libraryRepository.UpdateLastScan(parameters.Library); + return result.Map(_ => parameters.Library.Name); } else { @@ -95,7 +106,7 @@ public class parameters.Library.Name); } - return Unit.Default; + return parameters.Library.Name; } finally { diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs index f4f3d0c58..391941d68 100644 --- a/ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs @@ -1,5 +1,9 @@ using ErsatzTV.Application.MediaCards; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Search; @@ -8,21 +12,29 @@ using static ErsatzTV.Application.MediaCards.Mapper; namespace ErsatzTV.Application.Search; public class - QuerySearchIndexEpisodesHandler : IRequestHandler + QuerySearchIndexEpisodesHandler : IRequestHandler { + private readonly IEmbyPathReplacementService _embyPathReplacementService; + private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IMediaSourceRepository _mediaSourceRepository; + private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly ISearchIndex _searchIndex; private readonly ITelevisionRepository _televisionRepository; public QuerySearchIndexEpisodesHandler( ISearchIndex searchIndex, ITelevisionRepository televisionRepository, - IMediaSourceRepository mediaSourceRepository) + IMediaSourceRepository mediaSourceRepository, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService) { _searchIndex = searchIndex; _televisionRepository = televisionRepository; _mediaSourceRepository = mediaSourceRepository; + _plexPathReplacementService = plexPathReplacementService; + _jellyfinPathReplacementService = jellyfinPathReplacementService; + _embyPathReplacementService = embyPathReplacementService; } public async Task Handle( @@ -40,9 +52,21 @@ public class Option maybeEmby = await _mediaSourceRepository.GetAllEmby() .Map(list => list.HeadOrNone()); - List items = await _televisionRepository - .GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList()) - .Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby, true)).ToList()); + List episodes = await _televisionRepository + .GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList()); + + var items = new List(); + + foreach (EpisodeMetadata episodeMetadata in episodes) + { + string localPath = await episodeMetadata.Episode.GetLocalPath( + _plexPathReplacementService, + _jellyfinPathReplacementService, + _embyPathReplacementService, + false); + + items.Add(ProjectToViewModel(episodeMetadata, maybeJellyfin, maybeEmby, true, localPath)); + } return new TelevisionEpisodeCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); } diff --git a/ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs index 181d1c2ee..6ae035843 100644 --- a/ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs +++ b/ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs @@ -1,4 +1,9 @@ using ErsatzTV.Application.MediaCards; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Search; @@ -7,16 +12,26 @@ using static ErsatzTV.Application.MediaCards.Mapper; namespace ErsatzTV.Application.Search; public class - QuerySearchIndexMusicVideosHandler : IRequestHandler + QuerySearchIndexMusicVideosHandler : IRequestHandler { + private readonly IEmbyPathReplacementService _embyPathReplacementService; + private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IMusicVideoRepository _musicVideoRepository; + private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly ISearchIndex _searchIndex; - public QuerySearchIndexMusicVideosHandler(ISearchIndex searchIndex, IMusicVideoRepository musicVideoRepository) + public QuerySearchIndexMusicVideosHandler( + ISearchIndex searchIndex, + IMusicVideoRepository musicVideoRepository, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService) { _searchIndex = searchIndex; _musicVideoRepository = musicVideoRepository; + _plexPathReplacementService = plexPathReplacementService; + _jellyfinPathReplacementService = jellyfinPathReplacementService; + _embyPathReplacementService = embyPathReplacementService; } public async Task Handle( @@ -28,9 +43,21 @@ public class (request.PageNumber - 1) * request.PageSize, request.PageSize); - List items = await _musicVideoRepository - .GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList()) - .Map(list => list.Map(ProjectToViewModel).ToList()); + List musicVideos = await _musicVideoRepository + .GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList()); + + var items = new List(); + + foreach (MusicVideoMetadata musicVideoMetadata in musicVideos) + { + string localPath = await musicVideoMetadata.MusicVideo.GetLocalPath( + _plexPathReplacementService, + _jellyfinPathReplacementService, + _embyPathReplacementService, + false); + + items.Add(ProjectToViewModel(musicVideoMetadata, localPath)); + } return new MusicVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); } diff --git a/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs b/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs index 1c5174bfe..3f4884b87 100644 --- a/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs +++ b/ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs @@ -3,5 +3,6 @@ public enum MediaItemState { Normal = 0, - FileNotFound = 1 + FileNotFound = 1, + Unavailable = 2 } diff --git a/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs b/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs index 8c12c240f..ead2ee8fe 100644 --- a/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs +++ b/ErsatzTV.Core/Emby/EmbyPathReplacementService.cs @@ -23,12 +23,12 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService _logger = logger; } - public async Task GetReplacementEmbyPath(int libraryPathId, string path) + public async Task GetReplacementEmbyPath(int libraryPathId, string path, bool log = true) { List replacements = await _mediaSourceRepository.GetEmbyPathReplacementsByLibraryId(libraryPathId); - return GetReplacementEmbyPath(replacements, path); + return GetReplacementEmbyPath(replacements, path, log); } public string GetReplacementEmbyPath( diff --git a/ErsatzTV.Core/Errors/ScanCanceled.cs b/ErsatzTV.Core/Errors/ScanCanceled.cs new file mode 100644 index 000000000..502d9e054 --- /dev/null +++ b/ErsatzTV.Core/Errors/ScanCanceled.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Errors; + +public class ScanCanceled : BaseError +{ + public ScanCanceled() : base("Scan was canceled") + { + } +} diff --git a/ErsatzTV.Core/Extensions/MediaItemExtensions.cs b/ErsatzTV.Core/Extensions/MediaItemExtensions.cs index c56dafd8d..79d301f1d 100644 --- a/ErsatzTV.Core/Extensions/MediaItemExtensions.cs +++ b/ErsatzTV.Core/Extensions/MediaItemExtensions.cs @@ -1,4 +1,7 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; namespace ErsatzTV.Core.Extensions; @@ -14,4 +17,45 @@ public static class MediaItemExtensions Song s => s.MediaVersions.Head(), _ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) }; + + public static async Task GetLocalPath( + this MediaItem mediaItem, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, + bool log = true) + { + MediaVersion version = mediaItem.GetHeadVersion(); + + MediaFile file = version.MediaFiles.Head(); + string path = file.Path; + return mediaItem switch + { + PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath( + plexMovie.LibraryPathId, + path, + log), + PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath( + plexEpisode.LibraryPathId, + path, + log), + JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath( + jellyfinMovie.LibraryPathId, + path, + log), + JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath( + jellyfinEpisode.LibraryPathId, + path, + log), + EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath( + embyMovie.LibraryPathId, + path, + log), + EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath( + embyEpisode.LibraryPathId, + path, + log), + _ => path + }; + } } diff --git a/ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs b/ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs index b7c4d9f4e..3f3a4e51d 100644 --- a/ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs +++ b/ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs @@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Emby; public interface IEmbyPathReplacementService { - Task GetReplacementEmbyPath(int libraryPathId, string path); + Task GetReplacementEmbyPath(int libraryPathId, string path, bool log = true); string GetReplacementEmbyPath(List pathReplacements, string path, bool log = true); } diff --git a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs index c4b2a952d..a0b55486e 100644 --- a/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs +++ b/ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs @@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Jellyfin; public interface IJellyfinPathReplacementService { - Task GetReplacementJellyfinPath(int libraryPathId, string path); + Task GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true); string GetReplacementJellyfinPath(List pathReplacements, string path, bool log = true); } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs index 12f155c24..b3d601a0c 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs @@ -11,5 +11,6 @@ public interface IPlexMovieLibraryScanner PlexLibrary library, string ffmpegPath, string ffprobePath, - bool deepScan); + bool deepScan, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexPathReplacementService.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexPathReplacementService.cs index c3f8b79d7..3f1f17982 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexPathReplacementService.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexPathReplacementService.cs @@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Plex; public interface IPlexPathReplacementService { - Task GetReplacementPlexPath(int libraryPathId, string path); + Task GetReplacementPlexPath(int libraryPathId, string path, bool log = true); string GetReplacementPlexPath(List pathReplacements, string path, bool log = true); } diff --git a/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs index 9da73ab85..59e5ae86e 100644 --- a/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs @@ -11,5 +11,6 @@ public interface IPlexTelevisionLibraryScanner PlexLibrary library, string ffmpegPath, string ffprobePath, - bool deepScan); + bool deepScan, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs index 6ad130c56..97c9efee4 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories; public interface IMediaItemRepository { - Task> GetAllLanguageCodes(); + Task> GetAllKnownCultures(); Task> GetAllLanguageCodeCultures(); Task> FlagFileNotFound(LibraryPath libraryPath, string path); Task FlagNormal(MediaItem mediaItem); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index aa494e1c8..b9c0d399d 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -20,7 +20,6 @@ public interface IMovieRepository 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); Task> RemoveMissingJellyfinMovies(JellyfinLibrary library, List movieIds); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs new file mode 100644 index 000000000..70cf7109c --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface IPlexMovieRepository +{ + Task FlagNormal(PlexLibrary library, PlexMovie movie); + Task FlagUnavailable(PlexLibrary library, PlexMovie movie); + Task> FlagFileNotFound(PlexLibrary library, List plexMovieKeys); +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs new file mode 100644 index 000000000..6e109ce8f --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Plex; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface IPlexTelevisionRepository +{ + Task> GetExistingPlexShows(PlexLibrary library); + Task> GetExistingPlexSeasons(PlexLibrary library, PlexShow show); + Task> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season); + Task FlagNormal(PlexLibrary library, PlexEpisode episode); + Task FlagUnavailable(PlexLibrary library, PlexEpisode episode); + Task> FlagFileNotFoundShows(PlexLibrary library, List plexShowKeys); + Task> FlagFileNotFoundSeasons(PlexLibrary library, List plexSeasonKeys); + Task> FlagFileNotFoundEpisodes(PlexLibrary library, List plexEpisodeKeys); + Task SetPlexEtag(PlexShow show, string etag); + Task SetPlexEtag(PlexSeason season, string etag); + Task SetPlexEtag(PlexEpisode episode, string etag); +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index 2faed202b..2761fe230 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -1,6 +1,5 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Metadata; -using ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Interfaces.Repositories; @@ -46,15 +45,8 @@ public interface ITelevisionRepository Task AddStudio(ShowMetadata metadata, Studio studio); Task AddActor(ShowMetadata metadata, Actor actor); Task AddActor(EpisodeMetadata metadata, Actor actor); - Task> RemoveMissingPlexShows(PlexLibrary library, List showKeys); - Task RemoveMissingPlexSeasons(string showKey, List seasonKeys); - Task> RemoveMissingPlexEpisodes(string seasonKey, List episodeKeys); Task RemoveMetadata(Episode episode, EpisodeMetadata metadata); 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/Jellyfin/JellyfinPathReplacementService.cs b/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs index ba775d0a5..e31f3b68d 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs @@ -23,7 +23,7 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService _logger = logger; } - public async Task GetReplacementJellyfinPath(int libraryPathId, string path) + public async Task GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true) { List replacements = await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId); diff --git a/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs b/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs index 05847c36d..2385a5f25 100644 --- a/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs @@ -36,7 +36,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider await _slim.WaitAsync(); try { - _languageCodes.AddRange(await _mediaItemRepository.GetAllLanguageCodeCultures()); + _languageCodes.AddRange(await _mediaItemRepository.GetAllKnownCultures()); } finally { diff --git a/ErsatzTV.Core/Plex/PlexItemEtag.cs b/ErsatzTV.Core/Plex/PlexItemEtag.cs index 60ae18fe1..de7dfac1a 100644 --- a/ErsatzTV.Core/Plex/PlexItemEtag.cs +++ b/ErsatzTV.Core/Plex/PlexItemEtag.cs @@ -1,7 +1,10 @@ -namespace ErsatzTV.Core.Plex; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Plex; public class PlexItemEtag { public string Key { get; set; } public string Etag { get; set; } + public MediaItemState State { get; set; } } diff --git a/ErsatzTV.Core/Plex/PlexLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexLibraryScanner.cs index 8452ae93f..97674ccaa 100644 --- a/ErsatzTV.Core/Plex/PlexLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexLibraryScanner.cs @@ -15,7 +15,7 @@ public abstract class PlexLibraryScanner _logger = logger; } - protected async Task UpdateArtworkIfNeeded( + protected async Task UpdateArtworkIfNeeded( Domain.Metadata existingMetadata, Domain.Metadata incomingMetadata, ArtworkKind artworkKind) @@ -53,8 +53,10 @@ public abstract class PlexLibraryScanner existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind); }); + + return true; } - return Unit.Default; + return false; } } diff --git a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs index 69cb63ed7..697d1a2ec 100644 --- a/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; @@ -19,6 +20,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan private readonly IMediator _mediator; private readonly IMetadataRepository _metadataRepository; private readonly IMovieRepository _movieRepository; + private readonly IPlexMovieRepository _plexMovieRepository; private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexServerApiClient _plexServerApiClient; private readonly ISearchIndex _searchIndex; @@ -32,6 +34,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan ISearchRepository searchRepository, IMediator mediator, IMediaSourceRepository mediaSourceRepository, + IPlexMovieRepository plexMovieRepository, IPlexPathReplacementService plexPathReplacementService, ILocalFileSystem localFileSystem, ILocalStatisticsProvider localStatisticsProvider, @@ -46,6 +49,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan _searchRepository = searchRepository; _mediator = mediator; _mediaSourceRepository = mediaSourceRepository; + _plexMovieRepository = plexMovieRepository; _plexPathReplacementService = plexPathReplacementService; _localFileSystem = localFileSystem; _localStatisticsProvider = localStatisticsProvider; @@ -59,115 +63,187 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan PlexLibrary library, string ffmpegPath, string ffprobePath, - bool deepScan) + bool deepScan, + CancellationToken cancellationToken) + { + try + { + Either> entries = await _plexServerApiClient.GetMovieLibraryContents( + library, + connection, + token); + + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } + + return await ScanLibrary( + connection, + token, + library, + ffmpegPath, + ffprobePath, + deepScan, + entries.RightToSeq().Flatten().ToList(), + cancellationToken); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + // always commit the search index to prevent corruption + _searchIndex.Commit(); + } + } + + private async Task> ScanLibrary( + PlexConnection connection, + PlexServerAuthToken token, + PlexLibrary library, + string ffmpegPath, + string ffprobePath, + bool deepScan, + List movieEntries, + CancellationToken cancellationToken) { List existingMovies = await _movieRepository.GetExistingPlexMovies(library); List pathReplacements = await _mediaSourceRepository .GetPlexPathReplacements(library.MediaSourceId); - Either> entries = await _plexServerApiClient.GetMovieLibraryContents( - library, - connection, - token); - - await entries.Match( - async movieEntries => + foreach (PlexMovie incoming in movieEntries) + { + if (cancellationToken.IsCancellationRequested) { - var validMovies = new List(); - foreach (PlexMovie movie in movieEntries.OrderBy(m => m.MovieMetadata.Head().Title)) - { - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - movie.MediaVersions.Head().MediaFiles.Head().Path, - false); + return new ScanCanceled(); + } - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning("Skipping plex movie that does not exist at {Path}", localPath); - } - else - { - validMovies.Add(movie); - } + decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count; + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); + + if (await ShouldScanItem(library, pathReplacements, existingMovies, incoming, deepScan) == false) + { + continue; + } + + // TODO: figure out how to rebuild playlists + Either> maybeMovie = await _movieRepository + .GetOrAdd(library, incoming) + .BindT( + existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath)) + .BindT(existing => UpdateMetadata(existing, incoming, library, connection, token)) + .BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming)) + .BindT(existing => UpdateArtwork(existing, incoming)); + + if (maybeMovie.IsLeft) + { + foreach (BaseError error in maybeMovie.LeftToSeq()) + { + _logger.LogWarning( + "Error processing plex movie at {Key}: {Error}", + incoming.Key, + error.Value); } - foreach (PlexMovie incoming in validMovies) + continue; + } + + foreach (MediaItemScanResult result in maybeMovie.RightToSeq()) + { + await _movieRepository.SetPlexEtag(result.Item, incoming.Etag); + + string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path; + + string localPath = _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + plexPath, + false); + + if (_localFileSystem.FileExists(localPath)) { - decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); - - // deep scan will pull every movie from the plex api - if (!deepScan) - { - 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) - .BindT( - existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath)) - .BindT(existing => UpdateMetadata(existing, incoming, library, connection, token)) - .BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming)) - .BindT(existing => UpdateArtwork(existing, incoming)); - - await maybeMovie.Match( - async result => - { - await _movieRepository.SetPlexEtag(result.Item, incoming.Etag); - - if (result.IsAdded) - { - await _searchIndex.AddItems(_searchRepository, new List { result.Item }); - } - else if (result.IsUpdated) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { result.Item }); - } - }, - error => - { - _logger.LogWarning( - "Error processing plex movie at {Key}: {Error}", - incoming.Key, - error.Value); - return Task.CompletedTask; - }); + await _plexMovieRepository.FlagNormal(library, result.Item); + } + else + { + await _plexMovieRepository.FlagUnavailable(library, result.Item); } - var movieKeys = validMovies.Map(s => s.Key).ToList(); - List ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys); - await _searchIndex.RemoveItems(ids); + if (result.IsAdded) + { + await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + } + } + } - await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); - }, - error => - { - _logger.LogWarning( - "Error synchronizing plex library {Path}: {Error}", - library.Name, - error.Value); + // trash items that are no longer present on the media server + var fileNotFoundKeys = existingMovies.Map(m => m.Key).Except(movieEntries.Map(m => m.Key)).ToList(); + List ids = await _plexMovieRepository.FlagFileNotFound(library, fileNotFoundKeys); + await _searchIndex.RebuildItems(_searchRepository, ids); - return Task.CompletedTask; - }); + await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); - _searchIndex.Commit(); return Unit.Default; } + private async Task ShouldScanItem( + PlexLibrary library, + List pathReplacements, + List existingMovies, + PlexMovie incoming, + bool deepScan) + { + // deep scan will pull every movie individually from the plex api + if (!deepScan) + { + Option maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key); + string existingEtag = await maybeExisting + .Map(e => e.Etag ?? string.Empty) + .IfNoneAsync(string.Empty); + MediaItemState existingState = await maybeExisting + .Map(e => e.State) + .IfNoneAsync(MediaItemState.Normal); + + string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path; + + string localPath = _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + plexPath, + false); + + // if media is unavailable, only scan if file now exists + if (existingState == MediaItemState.Unavailable) + { + if (!_localFileSystem.FileExists(localPath)) + { + return false; + } + } + else if (existingEtag == incoming.Etag) + { + if (!_localFileSystem.FileExists(localPath)) + { + await _plexMovieRepository.FlagUnavailable(library, incoming); + } + + // _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key); + return false; + } + + _logger.LogDebug( + "UPDATE: Etag has changed for movie {Movie}", + incoming.MovieMetadata.Head().Title); + } + + return true; + } + private async Task>> UpdateStatistics( List pathReplacements, MediaItemScanResult result, @@ -179,7 +255,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head(); - if (existing.Etag != incoming.Etag) + if (result.IsAdded || existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0) { foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone()) { @@ -204,32 +280,36 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan incoming.MediaVersions.Head().MediaFiles.Head().Path, false); - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath); + // only refresh statistics if the file exists + if (_localFileSystem.FileExists(localPath)) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); + Either refreshResult = + await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath); - await refreshResult.Match( - async _ => - { - foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id)) + await refreshResult.Match( + async _ => { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated }); - } + foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id)) + { + 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); + 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 Task.CompletedTask; + }); + } } return result; @@ -480,9 +560,12 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata incomingMetadata = incoming.MovieMetadata.Head(); - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); - await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); + bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); + bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); + if (poster || fanArt) + { + await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); + } return result; } diff --git a/ErsatzTV.Core/Plex/PlexPathReplacementService.cs b/ErsatzTV.Core/Plex/PlexPathReplacementService.cs index c1c30915a..0d11d8b6f 100644 --- a/ErsatzTV.Core/Plex/PlexPathReplacementService.cs +++ b/ErsatzTV.Core/Plex/PlexPathReplacementService.cs @@ -23,12 +23,12 @@ public class PlexPathReplacementService : IPlexPathReplacementService _logger = logger; } - public async Task GetReplacementPlexPath(int libraryPathId, string path) + public async Task GetReplacementPlexPath(int libraryPathId, string path, bool log = true) { List replacements = await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId); - return GetReplacementPlexPath(replacements, path); + return GetReplacementPlexPath(replacements, path, log); } public string GetReplacementPlexPath(List pathReplacements, string path, bool log = true) diff --git a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs index df3d4d62f..b943f09b1 100644 --- a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; @@ -21,6 +22,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL private readonly IMetadataRepository _metadataRepository; private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexServerApiClient _plexServerApiClient; + private readonly IPlexTelevisionRepository _plexTelevisionRepository; private readonly ISearchIndex _searchIndex; private readonly ISearchRepository _searchRepository; private readonly ITelevisionRepository _televisionRepository; @@ -34,6 +36,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL IMediator mediator, IMediaSourceRepository mediaSourceRepository, IPlexPathReplacementService plexPathReplacementService, + IPlexTelevisionRepository plexTelevisionRepository, ILocalFileSystem localFileSystem, ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, @@ -48,6 +51,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL _mediator = mediator; _mediaSourceRepository = mediaSourceRepository; _plexPathReplacementService = plexPathReplacementService; + _plexTelevisionRepository = plexTelevisionRepository; _localFileSystem = localFileSystem; _localStatisticsProvider = localStatisticsProvider; _localSubtitlesProvider = localSubtitlesProvider; @@ -60,85 +64,129 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL PlexLibrary library, string ffmpegPath, string ffprobePath, - bool deepScan) + bool deepScan, + CancellationToken cancellationToken) { + try + { + Either> entries = await _plexServerApiClient.GetShowLibraryContents( + library, + connection, + token); + + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } + + return await ScanLibrary( + connection, + token, + library, + ffmpegPath, + ffprobePath, + deepScan, + entries.RightToSeq().Flatten().ToList(), + cancellationToken); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + // always commit the search index to prevent corruption + _searchIndex.Commit(); + } + } + + private async Task> ScanLibrary( + PlexConnection connection, + PlexServerAuthToken token, + PlexLibrary library, + string ffmpegPath, + string ffprobePath, + bool deepScan, + List showEntries, + CancellationToken cancellationToken) + { + List existingShows = await _plexTelevisionRepository.GetExistingPlexShows(library); + List pathReplacements = await _mediaSourceRepository .GetPlexPathReplacements(library.MediaSourceId); - Either> entries = await _plexServerApiClient.GetShowLibraryContents( - library, - connection, - token); - - return await entries.Match>>( - async showEntries => + foreach (PlexShow incoming in showEntries) + { + if (cancellationToken.IsCancellationRequested) { - foreach (PlexShow incoming in showEntries) + return new ScanCanceled(); + } + + decimal percentCompletion = (decimal)showEntries.IndexOf(incoming) / showEntries.Count; + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); + + // TODO: figure out how to rebuild playlists + Either> maybeShow = await _televisionRepository + .GetOrAddPlexShow(library, incoming) + .BindT(existing => UpdateMetadata(existing, incoming, library, connection, token, deepScan)) + .BindT(existing => UpdateArtwork(existing, incoming)); + + if (maybeShow.IsLeft) + { + foreach (BaseError error in maybeShow.LeftToSeq()) { - decimal percentCompletion = (decimal)showEntries.IndexOf(incoming) / showEntries.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); - - // TODO: figure out how to rebuild playlists - Either> maybeShow = await _televisionRepository - .GetOrAddPlexShow(library, incoming) - .BindT(existing => UpdateMetadata(existing, incoming, library, connection, token, deepScan)) - .BindT(existing => UpdateArtwork(existing, incoming)); - - await maybeShow.Match( - async result => - { - await ScanSeasons( - library, - pathReplacements, - result.Item, - connection, - token, - ffmpegPath, - ffprobePath, - deepScan); - - await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag); - - if (result.IsAdded) - { - await _searchIndex.AddItems(_searchRepository, new List { result.Item }); - } - else if (result.IsUpdated) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { result.Item }); - } - }, - error => - { - _logger.LogWarning( - "Error processing plex show at {Key}: {Error}", - incoming.Key, - error.Value); - return Task.CompletedTask; - }); + _logger.LogWarning( + "Error processing plex show at {Key}: {Error}", + incoming.Key, + error.Value); } - var showKeys = showEntries.Map(s => s.Key).ToList(); - List ids = - await _televisionRepository.RemoveMissingPlexShows(library, showKeys); - await _searchIndex.RemoveItems(ids); + continue; + } - await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); - - _searchIndex.Commit(); - return Unit.Default; - }, - error => + foreach (MediaItemScanResult result in maybeShow.RightToSeq()) { - _logger.LogWarning( - "Error synchronizing plex library {Path}: {Error}", - library.Name, - error.Value); + Either scanResult = await ScanSeasons( + library, + pathReplacements, + result.Item, + connection, + token, + ffmpegPath, + ffprobePath, + deepScan, + cancellationToken); - return Left(error).AsTask(); - }); + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag); + + // TODO: if any seasons are unavailable or not found, flag show as unavailable/not found + + if (result.IsAdded) + { + await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + } + else if (result.IsUpdated) + { + await _searchIndex.UpdateItems( + _searchRepository, + new List { result.Item }); + } + } + } + + // trash items that are no longer present on the media server + var fileNotFoundKeys = existingShows.Map(m => m.Key).Except(showEntries.Map(m => m.Key)).ToList(); + List ids = await _plexTelevisionRepository.FlagFileNotFoundShows(library, fileNotFoundKeys); + await _searchIndex.RebuildItems(_searchRepository, ids); + + await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); + + return Unit.Default; } private async Task>> UpdateMetadata( @@ -152,7 +200,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL PlexShow existing = result.Item; ShowMetadata existingMetadata = existing.ShowMetadata.Head(); - if (existing.Etag != incoming.Etag || deepScan) + if (result.IsAdded || existing.Etag != incoming.Etag || deepScan) { Either maybeMetadata = await _plexServerApiClient.GetShowMetadata( @@ -308,10 +356,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL ShowMetadata existingMetadata = existing.ShowMetadata.Head(); ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); - if (existing.Etag != incoming.Etag) + bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); + bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); + if (poster || fanArt) { - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); } @@ -326,79 +374,86 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL PlexServerAuthToken token, string ffmpegPath, string ffprobePath, - bool deepScan) + bool deepScan, + CancellationToken cancellationToken) { + List existingSeasons = await _plexTelevisionRepository.GetExistingPlexSeasons(library, show); + Either> entries = await _plexServerApiClient.GetShowSeasons( library, show, connection, token); - return await entries.Match>>( - async seasonEntries => - { - foreach (PlexSeason incoming in seasonEntries) - { - incoming.ShowId = show.Id; + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } - // TODO: figure out how to rebuild playlists - Either maybeSeason = await _televisionRepository - .GetOrAddPlexSeason(library, incoming) - .BindT(existing => UpdateMetadataAndArtwork(existing, incoming)); + var seasonEntries = entries.RightToSeq().Flatten().ToList(); + foreach (PlexSeason incoming in seasonEntries) + { + incoming.ShowId = show.Id; - await maybeSeason.Match( - async season => - { - await ScanEpisodes( - library, - pathReplacements, - season, - connection, - token, - ffmpegPath, - ffprobePath, - deepScan); + // TODO: figure out how to rebuild playlists + Either maybeSeason = await _televisionRepository + .GetOrAddPlexSeason(library, incoming) + .BindT(existing => UpdateMetadataAndArtwork(existing, incoming, deepScan)); - await _televisionRepository.SetPlexEtag(season, incoming.Etag); - - season.Show = show; - - await _searchIndex.AddItems(_searchRepository, new List { season }); - }, - error => - { - _logger.LogWarning( - "Error processing plex show at {Key}: {Error}", - incoming.Key, - error.Value); - return Task.CompletedTask; - }); - } - - var seasonKeys = seasonEntries.Map(s => s.Key).ToList(); - await _televisionRepository.RemoveMissingPlexSeasons(show.Key, seasonKeys); - - return Unit.Default; - }, - error => + foreach (BaseError error in maybeSeason.LeftToSeq()) { _logger.LogWarning( - "Error synchronizing plex library {Path}: {Error}", - library.Name, + "Error processing plex season at {Key}: {Error}", + incoming.Key, error.Value); - return Left(error).AsTask(); - }); + return error; + } + + foreach (PlexSeason season in maybeSeason.RightToSeq()) + { + Either scanResult = await ScanEpisodes( + library, + pathReplacements, + season, + connection, + token, + ffmpegPath, + ffprobePath, + deepScan, + cancellationToken); + + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + await _plexTelevisionRepository.SetPlexEtag(season, incoming.Etag); + + season.Show = show; + + // TODO: if any seasons are unavailable or not found, flag show as unavailable/not found + + await _searchIndex.AddItems(_searchRepository, new List { season }); + } + } + + var fileNotFoundKeys = existingSeasons.Map(m => m.Key).Except(seasonEntries.Map(m => m.Key)).ToList(); + List ids = await _plexTelevisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundKeys); + await _searchIndex.RebuildItems(_searchRepository, ids); + + return Unit.Default; } private async Task> UpdateMetadataAndArtwork( PlexSeason existing, - PlexSeason incoming) + PlexSeason incoming, + bool deepScan) { SeasonMetadata existingMetadata = existing.SeasonMetadata.Head(); SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head(); - if (existing.Etag != incoming.Etag) + if (existing.Etag != incoming.Etag || deepScan) { foreach (MetadataGuid guid in existingMetadata.Guids .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) @@ -447,9 +502,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL PlexServerAuthToken token, string ffmpegPath, string ffprobePath, - bool deepScan) + bool deepScan, + CancellationToken cancellationToken) { - List existingEpisodes = await _televisionRepository.GetExistingPlexEpisodes(library, season); + List existingEpisodes = await _plexTelevisionRepository.GetExistingPlexEpisodes(library, season); Either> entries = await _plexServerApiClient.GetSeasonEpisodes( library, @@ -457,106 +513,149 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL connection, token); - return await entries.Match>>( - async episodeEntries => + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } + + var episodeEntries = entries.RightToSeq().Flatten().ToList(); + foreach (PlexEpisode incoming in episodeEntries) + { + if (cancellationToken.IsCancellationRequested) { - var validEpisodes = new List(); - foreach (PlexEpisode episode in episodeEntries) - { - string localPath = _plexPathReplacementService.GetReplacementPlexPath( + return new ScanCanceled(); + } + + if (await ShouldScanItem(library, pathReplacements, existingEpisodes, incoming, deepScan) == false) + { + continue; + } + + incoming.SeasonId = season.Id; + + // TODO: figure out how to rebuild playlists + Either> maybeEpisode = await _televisionRepository + .GetOrAddPlexEpisode(library, incoming) + .BindT(existing => UpdateMetadata(existing, incoming)) + .BindT( + existing => UpdateStatistics( pathReplacements, - episode.MediaVersions.Head().MediaFiles.Head().Path, - false); + existing, + incoming, + library, + connection, + token, + ffmpegPath, + ffprobePath, + deepScan)) + .BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming)) + .BindT(existing => UpdateArtwork(existing, incoming)); - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning( - "Skipping plex episode that does not exist at {Path}", - localPath); - } - else - { - validEpisodes.Add(episode); - } - } - - foreach (PlexEpisode incoming in validEpisodes) - { - if (!deepScan) - { - 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 - Either> maybeEpisode = await _televisionRepository - .GetOrAddPlexEpisode(library, incoming) - .BindT(existing => UpdateMetadata(existing, incoming)) - .BindT( - existing => UpdateStatistics( - pathReplacements, - existing, - incoming, - library, - connection, - token, - ffmpegPath, - ffprobePath, - deepScan)) - .BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming)) - .BindT(existing => UpdateArtwork(existing, incoming)); - - await maybeEpisode.Match( - async result => - { - await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag); - - if (result.IsAdded) - { - await _searchIndex.AddItems(_searchRepository, new List { result.Item }); - } - else - { - await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); - } - }, - error => - { - _logger.LogWarning( - "Error processing plex episode at {Key}: {Error}", - incoming.Key, - error.Value); - return Task.CompletedTask; - }); - } - - var episodeKeys = validEpisodes.Map(s => s.Key).ToList(); - List ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - - return Unit.Default; - }, - error => + foreach (BaseError error in maybeEpisode.LeftToSeq()) { - _logger.LogWarning( - "Error synchronizing plex library {Path}: {Error}", - library.Name, - error.Value); + switch (error) + { + case ScanCanceled: + return error; + default: + _logger.LogWarning( + "Error processing plex episode at {Key}: {Error}", + incoming.Key, + error.Value); + break; + } + } - return Left(error).AsTask(); - }); + foreach (MediaItemScanResult result in maybeEpisode.RightToSeq()) + { + await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag); + + string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path; + + string localPath = _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + plexPath, + false); + + if (_localFileSystem.FileExists(localPath)) + { + await _plexTelevisionRepository.FlagNormal(library, result.Item); + } + else + { + await _plexTelevisionRepository.FlagUnavailable(library, result.Item); + } + + if (result.IsAdded) + { + await _searchIndex.AddItems(_searchRepository, new List { result.Item }); + } + else + { + await _searchIndex.UpdateItems(_searchRepository, new List { result.Item }); + } + } + } + + var fileNotFoundKeys = existingEpisodes.Map(m => m.Key).Except(episodeEntries.Map(m => m.Key)).ToList(); + List ids = await _plexTelevisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundKeys); + await _searchIndex.RebuildItems(_searchRepository, ids); + + _searchIndex.Commit(); + + return Unit.Default; + } + + private async Task ShouldScanItem( + PlexLibrary library, + List pathReplacements, + List existingEpisodes, + PlexEpisode incoming, + bool deepScan) + { + // deep scan will pull every episode individually from the plex api + if (!deepScan) + { + Option maybeExisting = existingEpisodes.Find(ie => ie.Key == incoming.Key); + string existingTag = await maybeExisting + .Map(e => e.Etag ?? string.Empty) + .IfNoneAsync(string.Empty); + MediaItemState existingState = await maybeExisting + .Map(e => e.State) + .IfNoneAsync(MediaItemState.Normal); + + string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path; + + string localPath = _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + plexPath, + false); + + // if media is unavailable, only scan if file now exists + if (existingState == MediaItemState.Unavailable) + { + if (!_localFileSystem.FileExists(localPath)) + { + return false; + } + } + else if (existingTag == incoming.Etag) + { + if (!_localFileSystem.FileExists(localPath)) + { + await _plexTelevisionRepository.FlagUnavailable(library, incoming); + } + + // _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key); + return false; + } + + // _logger.LogDebug( + // "UPDATE: Etag has changed for episode {Episode}", + // $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}"); + } + + return true; } private async Task>> UpdateMetadata( @@ -607,7 +706,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL MediaVersion existingVersion = existing.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head(); - if (existing.Etag != incoming.Etag || deepScan) + if (result.IsAdded || existing.Etag != incoming.Etag || deepScan || existingVersion.Streams.Count == 0) { foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone()) { @@ -634,7 +733,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL incoming.MediaVersions.Head().MediaFiles.Head().Path, false); - if (existing.Etag != incoming.Etag) + if ((existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0) && + _localFileSystem.FileExists(localPath)) { _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); refreshResult = await _localStatisticsProvider.RefreshStatistics( diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index 4919876df..017d96e28 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -14,18 +14,28 @@ public class MediaItemRepository : IMediaItemRepository public MediaItemRepository(IDbContextFactory dbContextFactory) => _dbContextFactory = dbContextFactory; - public async Task> GetAllLanguageCodes() + public async Task> GetAllKnownCultures() { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.Connection.QueryAsync( - @"SELECT LanguageCode FROM - (SELECT Language AS LanguageCode - FROM MediaStream WHERE Language IS NOT NULL - UNION ALL SELECT PreferredAudioLanguageCode AS LanguageCode - FROM Channel WHERE PreferredAudioLanguageCode IS NOT NULL) - GROUP BY LanguageCode - ORDER BY COUNT(LanguageCode) DESC") - .Map(result => result.ToList()); + + var result = new System.Collections.Generic.HashSet(); + + CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); + foreach (LanguageCode code in await dbContext.LanguageCodes.ToListAsync()) + { + Option maybeCulture = allCultures.Find( + c => string.Equals(code.ThreeCode1, c.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase) + || string.Equals( + code.ThreeCode2, + c.ThreeLetterISOLanguageName, + StringComparison.OrdinalIgnoreCase)); + foreach (CultureInfo culture in maybeCulture) + { + result.Add(culture); + } + } + + return result.ToList(); } public async Task> GetAllLanguageCodeCultures() @@ -96,4 +106,18 @@ public class MediaItemRepository : IMediaItemRepository return Unit.Default; } + + private async Task> GetAllLanguageCodes() + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT LanguageCode FROM + (SELECT Language AS LanguageCode + FROM MediaStream WHERE Language IS NOT NULL + UNION ALL SELECT PreferredAudioLanguageCode AS LanguageCode + FROM Channel WHERE PreferredAudioLanguageCode IS NOT NULL) + GROUP BY LanguageCode + ORDER BY COUNT(LanguageCode) DESC") + .Map(result => result.ToList()); + } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index b5e055e68..fdb86ae14 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -245,7 +245,7 @@ public class MovieRepository : IMovieRepository { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( - @"SELECT Key, Etag FROM PlexMovie + @"SELECT Key, Etag, MI.State 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 @@ -254,24 +254,6 @@ public class MovieRepository : IMovieRepository .Map(result => result.ToList()); } - public async Task> RemoveMissingPlexMovies(PlexLibrary library, List movieKeys) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - List ids = await dbContext.Connection.QueryAsync( - @"SELECT m.Id FROM MediaItem m - INNER JOIN PlexMovie pm ON pm.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys", - new { LibraryId = library.Id, Keys = movieKeys }).Map(result => result.ToList()); - - await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", - new { Ids = ids }); - - return ids; - } - public async Task UpdateSortTitle(MovieMetadata movieMetadata) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs new file mode 100644 index 000000000..614d537d7 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs @@ -0,0 +1,68 @@ +using Dapper; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories; + +public class PlexMovieRepository : IPlexMovieRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public PlexMovieRepository(IDbContextFactory dbContextFactory) => _dbContextFactory = dbContextFactory; + + public async Task FlagNormal(PlexLibrary library, PlexMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT PlexMovie.Id FROM PlexMovie + INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexMovie.Key = @Key)", + new { LibraryId = library.Id, movie.Key }).Map(count => count > 0); + } + + public async Task FlagUnavailable(PlexLibrary library, PlexMovie movie) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + movie.State = MediaItemState.Unavailable; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id IN + (SELECT PlexMovie.Id FROM PlexMovie + INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexMovie.Key = @Key)", + new { LibraryId = library.Id, movie.Key }).Map(count => count > 0); + } + + public async Task> FlagFileNotFound(PlexLibrary library, List plexMovieKeys) + { + if (plexMovieKeys.Count == 0) + { + return new List(); + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN PlexMovie ON PlexMovie.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE PlexMovie.Key IN @MovieKeys", + new { LibraryId = library.Id, MovieKeys = plexMovieKeys }) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", + new { Ids = ids }); + + return ids; + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs new file mode 100644 index 000000000..20fa228a4 --- /dev/null +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs @@ -0,0 +1,184 @@ +using Dapper; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Plex; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Infrastructure.Data.Repositories; + +public class PlexTelevisionRepository : IPlexTelevisionRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public PlexTelevisionRepository(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> GetExistingPlexShows(PlexLibrary library) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT PS.Key, PS.Etag, MI.State FROM PlexShow PS + INNER JOIN MediaItem MI on PS.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId", + new { LibraryId = library.Id }) + .Map(result => result.ToList()); + } + + public async Task> GetExistingPlexSeasons(PlexLibrary library, PlexShow show) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.QueryAsync( + @"SELECT PlexSeason.Key, PlexSeason.Etag, MI.State FROM PlexSeason + INNER JOIN Season S on PlexSeason.Id = S.Id + INNER JOIN MediaItem MI on S.Id = MI.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + INNER JOIN PlexShow PS ON S.ShowId = PS.Id + WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key", + new { LibraryId = library.Id, show.Key }) + .Map(result => result.ToList()); + } + + 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, MI.State 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 FlagNormal(PlexLibrary library, PlexEpisode episode) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + episode.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT PlexEpisode.Id FROM PlexEpisode + INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexEpisode.Key = @Key)", + new { LibraryId = library.Id, episode.Key }).Map(count => count > 0); + } + + public async Task FlagUnavailable(PlexLibrary library, PlexEpisode episode) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + episode.State = MediaItemState.Unavailable; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id IN + (SELECT PlexEpisode.Id FROM PlexEpisode + INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexEpisode.Key = @Key)", + new { LibraryId = library.Id, episode.Key }).Map(count => count > 0); + } + + public async Task> FlagFileNotFoundShows(PlexLibrary library, List plexShowKeys) + { + if (plexShowKeys.Count == 0) + { + return new List(); + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN PlexShow ON PlexShow.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE PlexShow.Key IN @ShowKeys", + new { LibraryId = library.Id, ShowKeys = plexShowKeys }) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", + new { Ids = ids }); + + return ids; + } + + public async Task> FlagFileNotFoundSeasons(PlexLibrary library, List plexSeasonKeys) + { + if (plexSeasonKeys.Count == 0) + { + return new List(); + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN PlexSeason ON PlexSeason.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE PlexSeason.Key IN @SeasonKeys", + new { LibraryId = library.Id, SeasonKeys = plexSeasonKeys }) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", + new { Ids = ids }); + + return ids; + } + + public async Task> FlagFileNotFoundEpisodes(PlexLibrary library, List plexEpisodeKeys) + { + if (plexEpisodeKeys.Count == 0) + { + return new List(); + } + + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN PlexEpisode ON PlexEpisode.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE PlexEpisode.Key IN @EpisodeKeys", + new { LibraryId = library.Id, EpisodeKeys = plexEpisodeKeys }) + .Map(result => result.ToList()); + + await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", + new { Ids = ids }); + + return ids; + } + + 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); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index f42acb7e4..7d4ff9e8a 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -3,7 +3,6 @@ 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; @@ -537,38 +536,6 @@ public class TelevisionRepository : ITelevisionRepository async () => await AddPlexEpisode(dbContext, library, item)); } - public async Task RemoveMissingPlexSeasons(string showKey, List seasonKeys) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.Connection.ExecuteAsync( - @"DELETE FROM MediaItem WHERE Id IN - (SELECT m.Id FROM MediaItem m - INNER JOIN Season s ON m.Id = s.Id - INNER JOIN PlexSeason ps ON ps.Id = m.Id - INNER JOIN PlexShow P on P.Id = s.ShowId - WHERE P.Key = @ShowKey AND ps.Key not in @Keys)", - new { ShowKey = showKey, Keys = seasonKeys }).ToUnit(); - } - - public async Task> RemoveMissingPlexEpisodes(string seasonKey, List episodeKeys) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - List ids = await dbContext.Connection.QueryAsync( - @"SELECT m.Id FROM MediaItem m - INNER JOIN Episode e ON m.Id = e.Id - INNER JOIN PlexEpisode pe ON pe.Id = m.Id - INNER JOIN PlexSeason P on P.Id = e.SeasonId - WHERE P.Key = @SeasonKey AND pe.Key not in @Keys", - new { SeasonKey = seasonKey, Keys = episodeKeys }).Map(result => result.ToList()); - - await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", - new { Ids = ids }); - - return ids; - } - public async Task RemoveMetadata(Episode episode, EpisodeMetadata metadata) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -603,45 +570,6 @@ 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(); @@ -777,24 +705,6 @@ public class TelevisionRepository : ITelevisionRepository .Map(result => result > 0); } - public async Task> RemoveMissingPlexShows(PlexLibrary library, List showKeys) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - List ids = await dbContext.Connection.QueryAsync( - @"SELECT m.Id FROM MediaItem m - INNER JOIN PlexShow ps ON ps.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys", - new { LibraryId = library.Id, Keys = showKeys }).Map(result => result.ToList()); - - await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", - new { Ids = ids }); - - return ids; - } - private static async Task> AddSeason( TvContext dbContext, Show show, diff --git a/ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.Designer.cs new file mode 100644 index 000000000..7767f7e89 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.Designer.cs @@ -0,0 +1,4140 @@ +// +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("20220424025823_Reset_EtagWithMissingPlexStatistics")] + partial class Reset_EtagWithMissingPlexStatistics + { + 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.EmbyCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EmbyCollection", (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.JellyfinCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Etag") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("JellyfinCollection", (string)null); + }); + + 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("PreferredAudioLanguageCode") + .HasColumnType("TEXT"); + + b.Property("PreferredSubtitleLanguageCode") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + 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("PreferredAudioLanguageCode") + .HasColumnType("TEXT"); + + b.Property("PreferredSubtitleLanguageCode") + .HasColumnType("TEXT"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SmartCollectionId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + 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("ExternalCollectionId") + .HasColumnType("TEXT"); + + 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/20220424025823_Reset_EtagWithMissingPlexStatistics.cs b/ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.cs new file mode 100644 index 000000000..64be34863 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Reset_EtagWithMissingPlexStatistics : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + // clear etag on plex movies where no streams are present (caused by bug in recent version) + migrationBuilder.Sql( + @"UPDATE PlexMovie SET Etag = NULL WHERE PlexMovie.Id IN + (SELECT PlexMovie.Id FROM PlexMovie + INNER JOIN Movie M on M.Id = PlexMovie.Id + INNER JOIN MediaVersion MV on M.Id = MV.MovieId + LEFT JOIN MediaStream MS on MV.Id = MS.MediaVersionId + WHERE MS.Id IS NULL)"); + + // clear etag on plex episodes where no streams are present (caused by bug in recent version) + migrationBuilder.Sql( + @"UPDATE PlexEpisode SET Etag = NULL WHERE PlexEpisode.Id IN + (SELECT PlexEpisode.Id FROM PlexEpisode + INNER JOIN Episode E on E.Id = PlexEpisode.Id + INNER JOIN MediaVersion MV on E.Id = MV.EpisodeId + LEFT JOIN MediaStream MS on MV.Id = MS.MediaVersionId + WHERE MS.Id IS NULL)"); + + // force scanning libraries with NULL etag movies + migrationBuilder.Sql( + @"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Library.Id IN + (SELECT DISTINCT Library.Id FROM Library + INNER JOIN LibraryPath LP on Library.Id = LP.LibraryId + INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId + INNER JOIN PlexMovie PM ON MI.Id = PM.Id + WHERE PM.Etag IS NULL)"); + + // force scanning libraries with NULL etag episodes + migrationBuilder.Sql( + @"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Library.Id IN + (SELECT DISTINCT Library.Id FROM Library + INNER JOIN LibraryPath LP on Library.Id = LP.LibraryId + INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId + INNER JOIN PlexEpisode PE ON MI.Id = PE.Id + WHERE PE.Etag IS NULL)"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/ErsatzTV/Pages/Artist.razor b/ErsatzTV/Pages/Artist.razor index e7fbee1e8..6adead51a 100644 --- a/ErsatzTV/Pages/Artist.razor +++ b/ErsatzTV/Pages/Artist.razor @@ -132,6 +132,12 @@ } + else if (musicVideo.State == MediaItemState.Unavailable) + { +
+ +
+ } } else @@ -144,6 +150,12 @@ } + else if (musicVideo.State == MediaItemState.Unavailable) + { +
+ +
+ } } @@ -176,6 +188,14 @@ @musicVideo.Path } + else if (musicVideo.State == MediaItemState.Unavailable) + { +
+ + Unavailable:  + @musicVideo.LocalPath +
+ } } diff --git a/ErsatzTV/Pages/Movie.razor b/ErsatzTV/Pages/Movie.razor index 63c4541e8..4f2311a73 100644 --- a/ErsatzTV/Pages/Movie.razor +++ b/ErsatzTV/Pages/Movie.razor @@ -46,6 +46,12 @@ } + else if (_movie.MediaItemState == MediaItemState.Unavailable) + { +
+ +
+ } }
@@ -81,6 +87,18 @@ } + else if (_movie.MediaItemState == MediaItemState.Unavailable) + { + + +
+ + Unavailable:  + @_movie.LocalPath +
+
+
+ } @if (_sortedContentRatings.Any()) diff --git a/ErsatzTV/Pages/TelevisionEpisodeList.razor b/ErsatzTV/Pages/TelevisionEpisodeList.razor index 67d048378..2eaf49853 100644 --- a/ErsatzTV/Pages/TelevisionEpisodeList.razor +++ b/ErsatzTV/Pages/TelevisionEpisodeList.razor @@ -89,6 +89,12 @@
} + else if (episode.State == MediaItemState.Unavailable) + { +
+ +
+ } } @@ -115,6 +121,14 @@ @episode.Path } + else if (episode.State == MediaItemState.Unavailable) + { +
+ + Unavailable:  + @episode.LocalPath +
+ }
Released: @episode.Aired.ToShortDateString()
diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index 0d372c9fc..b1784c9e0 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -29,6 +29,12 @@ } + else if (Data.State == MediaItemState.Unavailable) + { +
+ +
+ }
(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();