diff --git a/CHANGELOG.md b/CHANGELOG.md index d568b638..9f9c7048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Cleanly stop all library scans when service termination is requested - Fix health check crash when trash contains a show or a season - Fix ability of health check crash to crash home page +- Remove and ignore Season 0/Specials from Plex shows that have no specials ### Changed -- Update Plex, Jellyfin and Emby movie library scanners to share a significant amount of code +- Update Plex, Jellyfin and Emby movie and show library scanners to share a significant amount of code - This should help maintain feature parity going forward +- Jellyfin and Emby movie and show library scanners now support the `unavailable` media state - Optimize search-index rebuilding to complete 100x faster ### Added diff --git a/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs b/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs index 62417cf9..23866658 100644 --- a/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs @@ -1,5 +1,5 @@ using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; @@ -10,18 +10,13 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Emby; -public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner +public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner, IEmbyTelevisionLibraryScanner { private readonly IEmbyApiClient _embyApiClient; - private readonly ILocalFileSystem _localFileSystem; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly ILocalSubtitlesProvider _localSubtitlesProvider; - private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly IMediator _mediator; private readonly IEmbyPathReplacementService _pathReplacementService; - private readonly ISearchIndex _searchIndex; - private readonly ISearchRepository _searchRepository; private readonly IEmbyTelevisionRepository _televisionRepository; public EmbyTelevisionLibraryScanner( @@ -36,18 +31,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner ILocalSubtitlesProvider localSubtitlesProvider, IMediator mediator, ILogger logger) + : base( + localStatisticsProvider, + localSubtitlesProvider, + localFileSystem, + searchRepository, + searchIndex, + mediator, + logger) { _embyApiClient = embyApiClient; _mediaSourceRepository = mediaSourceRepository; _televisionRepository = televisionRepository; - _searchIndex = searchIndex; - _searchRepository = searchRepository; _pathReplacementService = pathReplacementService; - _localFileSystem = localFileSystem; - _localStatisticsProvider = localStatisticsProvider; - _localSubtitlesProvider = localSubtitlesProvider; - _mediator = mediator; - _logger = logger; } public async Task> ScanLibrary( @@ -58,435 +54,98 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner string ffprobePath, CancellationToken cancellationToken) { - try - { - List existingShows = await _televisionRepository.GetExistingShows(library); - - // TODO: maybe get quick list of item ids and etags from api to compare first - // TODO: paging? - - List pathReplacements = await _mediaSourceRepository - .GetEmbyPathReplacements(library.MediaSourceId); - - Either> maybeShows = await _embyApiClient.GetShowLibraryItems( - address, - apiKey, - library.ItemId); - - foreach (BaseError error in maybeShows.LeftToSeq()) - { - _logger.LogWarning( - "Error synchronizing emby library {Path}: {Error}", - library.Name, - error.Value); - } - - foreach (List shows in maybeShows.RightToSeq()) - { - Either scanResult = await ProcessShows( - address, - apiKey, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - existingShows, - shows, - cancellationToken); + List pathReplacements = + await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId); - foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) - { - return error; - } - - foreach (Unit _ in scanResult.RightToSeq()) - { - var incomingShowIds = shows.Map(s => s.ItemId).ToList(); - var showIds = existingShows - .Filter(i => !incomingShowIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); - await _searchIndex.RemoveItems(missingShowIds); - - await _televisionRepository.DeleteEmptySeasons(library); - List emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); - await _searchIndex.RemoveItems(emptyShowIds); - - await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); - } - } - - return Unit.Default; - } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + string GetLocalPath(EmbyEpisode episode) { - return new ScanCanceled(); + return _pathReplacementService.GetReplacementEmbyPath( + pathReplacements, + episode.GetHeadVersion().MediaFiles.Head().Path, + false); } - finally - { - _searchIndex.Commit(); - } - } - - private async Task> ProcessShows( - string address, - string apiKey, - EmbyLibrary library, - string ffmpegPath, - string ffprobePath, - List pathReplacements, - List existingShows, - List shows, - CancellationToken cancellationToken) - { - var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList(); - foreach (EmbyShow incoming in sortedShows) - { - if (cancellationToken.IsCancellationRequested) - { - return new ScanCanceled(); - } - decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); - - Option maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId); - if (maybeExisting.IsNone) - { - incoming.LibraryPathId = library.Paths.Head().Id; - - // _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title); - - if (await _televisionRepository.AddShow(incoming)) - { - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - - foreach (EmbyItemEtag existing in maybeExisting) - { - if (existing.Etag != incoming.Etag) - { - _logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title); - - incoming.LibraryPathId = library.Paths.Head().Id; - - Option maybeUpdated = await _televisionRepository.Update(incoming); - foreach (EmbyShow updated in maybeUpdated) - { - await _searchIndex.UpdateItems(_searchRepository, new List { updated }); - } - } - } - - List existingSeasons = - await _televisionRepository.GetExistingSeasons(library, incoming.ItemId); - - Either> maybeSeasons = - await _embyApiClient.GetSeasonLibraryItems(address, apiKey, incoming.ItemId); - - foreach (BaseError error in maybeSeasons.LeftToSeq()) - { - _logger.LogWarning( - "Error synchronizing emby library {Path}: {Error}", - library.Name, - error.Value); - } - - foreach (List seasons in maybeSeasons.RightToSeq()) - { - Either scanResult = await ProcessSeasons( - address, - apiKey, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - incoming, - existingSeasons, - seasons, - cancellationToken); - - foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) - { - return error; - } - - foreach (Unit _ in scanResult.RightToSeq()) - { - var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); - var seasonIds = existingSeasons - .Filter(i => !incomingSeasonIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - await _televisionRepository.RemoveMissingSeasons(library, seasonIds); - } - } - } - - return Unit.Default; + return await ScanLibrary( + _televisionRepository, + new EmbyConnectionParameters(address, apiKey), + library, + GetLocalPath, + ffmpegPath, + ffprobePath, + false, + cancellationToken); } - private async Task> ProcessSeasons( - string address, - string apiKey, - EmbyLibrary library, - string ffmpegPath, - string ffprobePath, - List pathReplacements, - EmbyShow show, - List existingSeasons, - List seasons, - CancellationToken cancellationToken) - { - foreach (EmbySeason incoming in seasons) - { - if (cancellationToken.IsCancellationRequested) - { - return new ScanCanceled(); - } + protected override Task>> GetShowLibraryItems( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library) => + _embyApiClient.GetShowLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + library.ItemId); - Option maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); - if (maybeExisting.IsNone) - { - incoming.LibraryPathId = library.Paths.Head().Id; + protected override string MediaServerItemId(EmbyShow show) => show.ItemId; + protected override string MediaServerItemId(EmbySeason season) => season.ItemId; + protected override string MediaServerItemId(EmbyEpisode episode) => episode.ItemId; - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season}", - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title); + protected override string MediaServerEtag(EmbyShow show) => show.Etag; + protected override string MediaServerEtag(EmbySeason season) => season.Etag; + protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag; - if (await _televisionRepository.AddSeason(show, incoming)) - { - incoming.Show = show; - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - - foreach (EmbyItemEtag existing in maybeExisting) - { - if (existing.Etag != incoming.Etag) - { - _logger.LogDebug( - "UPDATE: Etag has changed for show {Show} season {Season}", - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title); - - incoming.ShowId = show.Id; - incoming.LibraryPathId = library.Paths.Head().Id; - - foreach (EmbySeason updated in await _televisionRepository.Update(incoming)) - { - incoming.Show = show; - - foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id)) - { - await _searchIndex.UpdateItems(_searchRepository, new List { toIndex }); - } - } - } - } - - List existingEpisodes = - await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); - - Either> maybeEpisodes = - await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId); - - foreach (BaseError error in maybeEpisodes.LeftToSeq()) - { - _logger.LogWarning( - "Error synchronizing emby library {Path}: {Error}", - library.Name, - error.Value); - } - - foreach (List episodes in maybeEpisodes.RightToSeq()) - { - var validEpisodes = new List(); - foreach (EmbyEpisode episode in episodes) - { - string localPath = _pathReplacementService.GetReplacementEmbyPath( - pathReplacements, - episode.MediaVersions.Head().MediaFiles.Head().Path, - false); - - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning( - "Skipping emby episode that does not exist at {Path}", - localPath); - } - else - { - validEpisodes.Add(episode); - } - } - - Either scanResult = await ProcessEpisodes( - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - incoming, - existingEpisodes, - validEpisodes, - cancellationToken); - - foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) - { - return error; - } - - foreach (Unit _ in scanResult.RightToSeq()) - { - var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList(); - var episodeIds = existingEpisodes - .Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List missingEpisodeIds = - await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); - await _searchIndex.RemoveItems(missingEpisodeIds); - _searchIndex.Commit(); - } - } - } - - return Unit.Default; - } - - private async Task> ProcessEpisodes( - string showName, - string seasonName, + protected override Task>> GetSeasonLibraryItems( EmbyLibrary library, - string ffmpegPath, - string ffprobePath, - List pathReplacements, - EmbySeason season, - List existingEpisodes, - List episodes, - CancellationToken cancellationToken) - { - foreach (EmbyEpisode incoming in episodes) - { - if (cancellationToken.IsCancellationRequested) - { - return new ScanCanceled(); - } - - EmbyEpisode incomingEpisode = incoming; - var updateStatistics = false; - - Option maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); - if (maybeExisting.IsNone) - { - try - { - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season} episode {Episode}", - showName, - seasonName, - incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); - - if (await _televisionRepository.AddEpisode(season, incoming)) - { - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error adding episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - } - - foreach (EmbyItemEtag existing in maybeExisting) - { - try - { - if (existing.Etag != incoming.Etag) - { - _logger.LogDebug( - "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", - showName, - seasonName, - incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); - - updateStatistics = true; - incoming.SeasonId = season.Id; - incoming.LibraryPathId = library.Paths.Head().Id; - - Option maybeUpdated = await _televisionRepository.Update(incoming); - foreach (EmbyEpisode updated in maybeUpdated) - { - await _searchIndex.UpdateItems(_searchRepository, new List { updated }); - incomingEpisode = updated; - } - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error updating episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - } - - if (updateStatistics) - { - string localPath = _pathReplacementService.GetReplacementEmbyPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); - - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics( - ffmpegPath, - ffprobePath, - incomingEpisode, - localPath); - - if (refreshResult.Map(t => t).IfLeft(false)) - { - refreshResult = await UpdateSubtitles(incomingEpisode, localPath); - } - - foreach (BaseError error in refreshResult.LeftToSeq()) - { - _logger.LogWarning( - "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", - "Statistics", - localPath, - error.Value); - } - } - } + EmbyConnectionParameters connectionParameters, + EmbyShow show) => + _embyApiClient.GetSeasonLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + show.ItemId); + + protected override Task>> GetEpisodeLibraryItems( + EmbyLibrary library, + EmbyConnectionParameters connectionParameters, + EmbySeason season) => + _embyApiClient.GetEpisodeLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + season.ItemId); + + protected override Task> GetFullMetadata( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library, + MediaItemScanResult result, + EmbyShow incoming, + bool deepScan) => + Task.FromResult(Option.None); - return Unit.Default; - } + protected override Task> GetFullMetadata( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library, + MediaItemScanResult result, + EmbySeason incoming, + bool deepScan) => + Task.FromResult(Option.None); - private async Task> UpdateSubtitles(EmbyEpisode episode, string localPath) - { - try - { - return await _localSubtitlesProvider.UpdateSubtitles(episode, localPath, false); - } - catch (Exception ex) - { - return BaseError.New(ex.ToString()); - } - } + protected override Task> GetFullMetadata( + EmbyConnectionParameters connectionParameters, + EmbyLibrary library, + MediaItemScanResult result, + EmbyEpisode incoming, + bool deepScan) => + Task.FromResult(Option.None); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + ShowMetadata fullMetadata) => + Task.FromResult>>(result); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + SeasonMetadata fullMetadata) => + Task.FromResult>>(result); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + EpisodeMetadata fullMetadata) => + Task.FromResult>>(result); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs index 0d337732..ccf4be05 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs @@ -3,20 +3,7 @@ using ErsatzTV.Core.Emby; namespace ErsatzTV.Core.Interfaces.Repositories; -public interface IEmbyTelevisionRepository +public interface IEmbyTelevisionRepository : IMediaServerTelevisionRepository { - Task> GetExistingShows(EmbyLibrary library); - Task> GetExistingSeasons(EmbyLibrary library, string showItemId); - Task> GetExistingEpisodes(EmbyLibrary library, string seasonItemId); - Task AddShow(EmbyShow show); - Task> Update(EmbyShow show); - Task AddSeason(EmbyShow show, EmbySeason season); - Task> Update(EmbySeason season); - Task AddEpisode(EmbySeason season, EmbyEpisode episode); - Task> Update(EmbyEpisode episode); - Task> RemoveMissingShows(EmbyLibrary library, List showIds); - Task RemoveMissingSeasons(EmbyLibrary library, List seasonIds); - Task> RemoveMissingEpisodes(EmbyLibrary library, List episodeIds); - Task DeleteEmptySeasons(EmbyLibrary library); - Task> DeleteEmptyShows(EmbyLibrary library); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs index 98ec5977..4fa452a2 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs @@ -3,20 +3,8 @@ using ErsatzTV.Core.Jellyfin; namespace ErsatzTV.Core.Interfaces.Repositories; -public interface IJellyfinTelevisionRepository +public interface IJellyfinTelevisionRepository : IMediaServerTelevisionRepository { - Task> GetExistingShows(JellyfinLibrary library); - Task> GetExistingSeasons(JellyfinLibrary library, string showItemId); - Task> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId); - Task AddShow(JellyfinShow show); - Task> Update(JellyfinShow show); - Task AddSeason(JellyfinShow show, JellyfinSeason season); - Task> Update(JellyfinSeason season); - Task AddEpisode(JellyfinSeason season, JellyfinEpisode episode); - Task> Update(JellyfinEpisode episode); - Task> RemoveMissingShows(JellyfinLibrary library, List showIds); - Task RemoveMissingSeasons(JellyfinLibrary library, List seasonIds); - Task> RemoveMissingEpisodes(JellyfinLibrary library, List episodeIds); - Task DeleteEmptySeasons(JellyfinLibrary library); - Task> DeleteEmptyShows(JellyfinLibrary library); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs new file mode 100644 index 00000000..b81509a4 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs @@ -0,0 +1,26 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Metadata; + +namespace ErsatzTV.Core.Interfaces.Repositories; + +public interface IMediaServerTelevisionRepository where TLibrary : Library + where TShow : Show + where TSeason : Season + where TEpisode : Episode + where TEtag : MediaServerItemEtag +{ + Task> GetExistingShows(TLibrary library); + Task> GetExistingSeasons(TLibrary library, TShow show); + Task> GetExistingEpisodes(TLibrary library, TSeason season); + Task>> GetOrAdd(TLibrary library, TShow item); + Task>> GetOrAdd(TLibrary library, TSeason item); + Task>> GetOrAdd(TLibrary library, TEpisode item); + Task SetEtag(TShow show, string etag); + Task SetEtag(TSeason season, string etag); + Task SetEtag(TEpisode episode, string etag); + Task FlagNormal(TLibrary library, TEpisode episode); + Task> FlagFileNotFoundShows(TLibrary library, List showItemIds); + Task> FlagFileNotFoundSeasons(TLibrary library, List seasonItemIds); + Task> FlagFileNotFoundEpisodes(TLibrary library, List episodeItemIds); + Task> FlagUnavailable(TLibrary library, TEpisode episode); +} diff --git a/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs index 9010aa7b..76f16ea4 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs @@ -3,17 +3,7 @@ using ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Interfaces.Repositories; -public interface IPlexTelevisionRepository +public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository { - 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 2761fe23..cccaceb7 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -34,12 +34,6 @@ public interface ITelevisionRepository Task DeleteByPath(LibraryPath libraryPath, string path); Task DeleteEmptySeasons(LibraryPath libraryPath); Task> DeleteEmptyShows(LibraryPath libraryPath); - Task>> GetOrAddPlexShow(PlexLibrary library, PlexShow item); - Task> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item); - - Task>> - GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item); - Task AddGenre(ShowMetadata metadata, Genre genre); Task AddTag(Domain.Metadata metadata, Tag tag); Task AddStudio(ShowMetadata metadata, Studio studio); diff --git a/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs b/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs index a2a5560d..599023b6 100644 --- a/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs @@ -1,5 +1,5 @@ using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; @@ -10,18 +10,14 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Jellyfin; -public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanner +public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner, IJellyfinTelevisionLibraryScanner { private readonly IJellyfinApiClient _jellyfinApiClient; - private readonly ILocalFileSystem _localFileSystem; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly ILocalSubtitlesProvider _localSubtitlesProvider; - private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly IMediator _mediator; private readonly IJellyfinPathReplacementService _pathReplacementService; - private readonly ISearchIndex _searchIndex; - private readonly ISearchRepository _searchRepository; private readonly IJellyfinTelevisionRepository _televisionRepository; public JellyfinTelevisionLibraryScanner( @@ -36,18 +32,19 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne ILocalSubtitlesProvider localSubtitlesProvider, IMediator mediator, ILogger logger) + : base( + localStatisticsProvider, + localSubtitlesProvider, + localFileSystem, + searchRepository, + searchIndex, + mediator, + logger) { _jellyfinApiClient = jellyfinApiClient; _mediaSourceRepository = mediaSourceRepository; _televisionRepository = televisionRepository; - _searchIndex = searchIndex; - _searchRepository = searchRepository; _pathReplacementService = pathReplacementService; - _localFileSystem = localFileSystem; - _localStatisticsProvider = localStatisticsProvider; - _localSubtitlesProvider = localSubtitlesProvider; - _mediator = mediator; - _logger = logger; } public async Task> ScanLibrary( @@ -58,440 +55,101 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne string ffprobePath, CancellationToken cancellationToken) { - try - { - List existingShows = await _televisionRepository.GetExistingShows(library); - - // TODO: maybe get quick list of item ids and etags from api to compare first - // TODO: paging? - - List pathReplacements = await _mediaSourceRepository - .GetJellyfinPathReplacements(library.MediaSourceId); - - Either> maybeShows = await _jellyfinApiClient.GetShowLibraryItems( - address, - apiKey, - library.MediaSourceId, - library.ItemId); - - foreach (BaseError error in maybeShows.LeftToSeq()) - { - _logger.LogWarning( - "Error synchronizing jellyfin library {Path}: {Error}", - library.Name, - error.Value); - } - - foreach (List shows in maybeShows.RightToSeq()) - { - Either scanResult = await ProcessShows( - address, - apiKey, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - existingShows, - shows, - cancellationToken); + List pathReplacements = + await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId); - foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) - { - return error; - } - - foreach (Unit _ in scanResult.RightToSeq()) - { - var incomingShowIds = shows.Map(s => s.ItemId).ToList(); - var showIds = existingShows - .Filter(i => !incomingShowIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); - await _searchIndex.RemoveItems(missingShowIds); - - await _televisionRepository.DeleteEmptySeasons(library); - List emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); - await _searchIndex.RemoveItems(emptyShowIds); - - await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); - } - } - - return Unit.Default; - } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + string GetLocalPath(JellyfinEpisode episode) { - return new ScanCanceled(); + return _pathReplacementService.GetReplacementJellyfinPath( + pathReplacements, + episode.GetHeadVersion().MediaFiles.Head().Path, + false); } - finally - { - _searchIndex.Commit(); - } - } - - private async Task> ProcessShows( - string address, - string apiKey, - JellyfinLibrary library, - string ffmpegPath, - string ffprobePath, - List pathReplacements, - List existingShows, - List shows, - CancellationToken cancellationToken) - { - var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList(); - foreach (JellyfinShow incoming in sortedShows) - { - if (cancellationToken.IsCancellationRequested) - { - return new ScanCanceled(); - } - decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count; - await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); - - Option maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId); - if (maybeExisting.IsNone) - { - incoming.LibraryPathId = library.Paths.Head().Id; - - // _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title); - - if (await _televisionRepository.AddShow(incoming)) - { - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - - foreach (JellyfinItemEtag existing in maybeExisting) - { - if (existing.Etag != incoming.Etag) - { - _logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title); - - incoming.LibraryPathId = library.Paths.Head().Id; - - Option maybeUpdated = await _televisionRepository.Update(incoming); - foreach (JellyfinShow updated in maybeUpdated) - { - await _searchIndex.UpdateItems(_searchRepository, new List { updated }); - } - } - } - - List existingSeasons = - await _televisionRepository.GetExistingSeasons(library, incoming.ItemId); - - Either> maybeSeasons = - await _jellyfinApiClient.GetSeasonLibraryItems(address, apiKey, library.MediaSourceId, incoming.ItemId); - - foreach (BaseError error in maybeSeasons.LeftToSeq()) - { - _logger.LogWarning( - "Error synchronizing jellyfin library {Path}: {Error}", - library.Name, - error.Value); - } - - foreach (List seasons in maybeSeasons.RightToSeq()) - { - Either scanResult = await ProcessSeasons( - address, - apiKey, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - incoming, - existingSeasons, - seasons, - cancellationToken); - - foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) - { - return error; - } - - foreach (Unit _ in scanResult.RightToSeq()) - { - var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); - var seasonIds = existingSeasons - .Filter(i => !incomingSeasonIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - await _televisionRepository.RemoveMissingSeasons(library, seasonIds); - } - } - } - - return Unit.Default; + return await ScanLibrary( + _televisionRepository, + new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId), + library, + GetLocalPath, + ffmpegPath, + ffprobePath, + false, + cancellationToken); } - private async Task> ProcessSeasons( - string address, - string apiKey, - JellyfinLibrary library, - string ffmpegPath, - string ffprobePath, - List pathReplacements, - JellyfinShow show, - List existingSeasons, - List seasons, - CancellationToken cancellationToken) - { - foreach (JellyfinSeason incoming in seasons) - { - if (cancellationToken.IsCancellationRequested) - { - return new ScanCanceled(); - } + protected override Task>> GetShowLibraryItems( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library) => + _jellyfinApiClient.GetShowLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + library.MediaSourceId, + library.ItemId); - Option maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); - if (maybeExisting.IsNone) - { - incoming.LibraryPathId = library.Paths.Head().Id; + protected override string MediaServerItemId(JellyfinShow show) => show.ItemId; + protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId; + protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId; - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season}", - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title); + protected override string MediaServerEtag(JellyfinShow show) => show.Etag; + protected override string MediaServerEtag(JellyfinSeason season) => season.Etag; + protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag; - if (await _televisionRepository.AddSeason(show, incoming)) - { - incoming.Show = show; - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - - foreach (JellyfinItemEtag existing in maybeExisting) - { - if (existing.Etag != incoming.Etag) - { - _logger.LogDebug( - "UPDATE: Etag has changed for show {Show} season {Season}", - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title); - - incoming.ShowId = show.Id; - incoming.LibraryPathId = library.Paths.Head().Id; - - foreach (JellyfinSeason updated in await _televisionRepository.Update(incoming)) - { - incoming.Show = show; - - foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id)) - { - await _searchIndex.UpdateItems(_searchRepository, new List { toIndex }); - } - } - } - } - - List existingEpisodes = - await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); - - Either> maybeEpisodes = - await _jellyfinApiClient.GetEpisodeLibraryItems( - address, - apiKey, - library.MediaSourceId, - incoming.ItemId); - - foreach (BaseError error in maybeEpisodes.LeftToSeq()) - { - _logger.LogWarning( - "Error synchronizing jellyfin library {Path}: {Error}", - library.Name, - error.Value); - } - - foreach (List episodes in maybeEpisodes.RightToSeq()) - { - var validEpisodes = new List(); - foreach (JellyfinEpisode episode in episodes) - { - string localPath = _pathReplacementService.GetReplacementJellyfinPath( - pathReplacements, - episode.MediaVersions.Head().MediaFiles.Head().Path, - false); - - if (!_localFileSystem.FileExists(localPath)) - { - _logger.LogWarning( - "Skipping jellyfin episode that does not exist at {Path}", - localPath); - } - else - { - validEpisodes.Add(episode); - } - } - - Either scanResult = await ProcessEpisodes( - show.ShowMetadata.Head().Title, - incoming.SeasonMetadata.Head().Title, - library, - ffmpegPath, - ffprobePath, - pathReplacements, - incoming, - existingEpisodes, - validEpisodes, - cancellationToken); - - foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) - { - return error; - } - - foreach (Unit _ in scanResult.RightToSeq()) - { - var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList(); - var episodeIds = existingEpisodes - .Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) - .Map(m => m.ItemId) - .ToList(); - List missingEpisodeIds = - await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); - await _searchIndex.RemoveItems(missingEpisodeIds); - _searchIndex.Commit(); - } - } - } - - return Unit.Default; - } - - private async Task> ProcessEpisodes( - string showName, - string seasonName, + protected override Task>> GetSeasonLibraryItems( JellyfinLibrary library, - string ffmpegPath, - string ffprobePath, - List pathReplacements, - JellyfinSeason season, - List existingEpisodes, - List episodes, - CancellationToken cancellationToken) - { - foreach (JellyfinEpisode incoming in episodes) - { - if (cancellationToken.IsCancellationRequested) - { - return new ScanCanceled(); - } - - JellyfinEpisode incomingEpisode = incoming; - var updateStatistics = false; - - Option maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); - if (maybeExisting.IsNone) - { - try - { - updateStatistics = true; - incoming.LibraryPathId = library.Paths.Head().Id; - - _logger.LogDebug( - "INSERT: Item id is new for show {Show} season {Season} episode {Episode}", - showName, - seasonName, - incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); - - if (await _televisionRepository.AddEpisode(season, incoming)) - { - await _searchIndex.AddItems(_searchRepository, new List { incoming }); - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error adding episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - } - - foreach (JellyfinItemEtag existing in maybeExisting) - { - try - { - if (existing.Etag != incoming.Etag) - { - _logger.LogDebug( - "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", - showName, - seasonName, - incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber)); - - updateStatistics = true; - incoming.SeasonId = season.Id; - incoming.LibraryPathId = library.Paths.Head().Id; - - Option maybeUpdated = await _televisionRepository.Update(incoming); - foreach (JellyfinEpisode updated in maybeUpdated) - { - await _searchIndex.UpdateItems(_searchRepository, new List { updated }); - incomingEpisode = updated; - } - } - } - catch (Exception ex) - { - updateStatistics = false; - _logger.LogError( - ex, - "Error updating episode {Path}", - incoming.MediaVersions.Head().MediaFiles.Head().Path); - } - } - - if (updateStatistics) - { - string localPath = _pathReplacementService.GetReplacementJellyfinPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); - - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - Either refreshResult = - await _localStatisticsProvider.RefreshStatistics( - ffmpegPath, - ffprobePath, - incomingEpisode, - localPath); - - if (refreshResult.Map(t => t).IfLeft(false)) - { - refreshResult = await UpdateSubtitles(incomingEpisode, localPath); - } - - foreach (BaseError error in refreshResult.LeftToSeq()) - { - _logger.LogWarning( - "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", - "Statistics", - localPath, - error.Value); - } - } - } + JellyfinConnectionParameters connectionParameters, + JellyfinShow show) => + _jellyfinApiClient.GetSeasonLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + library.MediaSourceId, + show.ItemId); + + protected override Task>> GetEpisodeLibraryItems( + JellyfinLibrary library, + JellyfinConnectionParameters connectionParameters, + JellyfinSeason season) => + _jellyfinApiClient.GetEpisodeLibraryItems( + connectionParameters.Address, + connectionParameters.ApiKey, + library.MediaSourceId, + season.ItemId); + + protected override Task> GetFullMetadata( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinShow incoming, + bool deepScan) => + Task.FromResult(Option.None); - return Unit.Default; - } + protected override Task> GetFullMetadata( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinSeason incoming, + bool deepScan) => + Task.FromResult(Option.None); - private async Task> UpdateSubtitles(JellyfinEpisode episode, string localPath) - { - try - { - return await _localSubtitlesProvider.UpdateSubtitles(episode, localPath, false); - } - catch (Exception ex) - { - return BaseError.New(ex.ToString()); - } - } + protected override Task> GetFullMetadata( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinEpisode incoming, + bool deepScan) => + Task.FromResult(Option.None); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + ShowMetadata fullMetadata) => + Task.FromResult>>(result); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + SeasonMetadata fullMetadata) => + Task.FromResult>>(result); + + protected override Task>> UpdateMetadata( + MediaItemScanResult result, + EpisodeMetadata fullMetadata) => + Task.FromResult>>(result); } diff --git a/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs b/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs index ec787027..0388109a 100644 --- a/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs @@ -94,14 +94,15 @@ public abstract class MediaServerMovieLibraryScanner existingMovies = await movieRepository.GetExistingMovies(library); - foreach (TMovie incoming in movieEntries) + var sortedMovies = movieEntries.OrderBy(m => m.MovieMetadata.Head().SortTitle).ToList(); + foreach (TMovie incoming in sortedMovies) { if (cancellationToken.IsCancellationRequested) { return new ScanCanceled(); } - decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count; + decimal percentCompletion = (decimal)sortedMovies.IndexOf(incoming) / sortedMovies.Count; await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); string localPath = getLocalPath(incoming); @@ -167,7 +168,7 @@ public abstract class MediaServerMovieLibraryScanner m.MediaServerItemId) .Except(movieEntries.Map(MediaServerItemId)).ToList(); List ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds); diff --git a/ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs b/ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs new file mode 100644 index 00000000..292148cb --- /dev/null +++ b/ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs @@ -0,0 +1,617 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.MediaServer; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Core.Metadata; + +public abstract class MediaServerTelevisionLibraryScanner + where TConnectionParameters : MediaServerConnectionParameters + where TLibrary : Library + where TShow : Show + where TSeason : Season + where TEpisode : Episode + where TEtag : MediaServerItemEtag +{ + private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalStatisticsProvider _localStatisticsProvider; + private readonly ILocalSubtitlesProvider _localSubtitlesProvider; + private readonly ILogger _logger; + private readonly IMediator _mediator; + private readonly ISearchIndex _searchIndex; + private readonly ISearchRepository _searchRepository; + + protected MediaServerTelevisionLibraryScanner( + ILocalStatisticsProvider localStatisticsProvider, + ILocalSubtitlesProvider localSubtitlesProvider, + ILocalFileSystem localFileSystem, + ISearchRepository searchRepository, + ISearchIndex searchIndex, + IMediator mediator, + ILogger logger) + { + _localStatisticsProvider = localStatisticsProvider; + _localSubtitlesProvider = localSubtitlesProvider; + _localFileSystem = localFileSystem; + _searchRepository = searchRepository; + _searchIndex = searchIndex; + _mediator = mediator; + _logger = logger; + } + + protected async Task> ScanLibrary( + IMediaServerTelevisionRepository televisionRepository, + TConnectionParameters connectionParameters, + TLibrary library, + Func getLocalPath, + string ffmpegPath, + string ffprobePath, + bool deepScan, + CancellationToken cancellationToken) + { + try + { + Either> entries = await GetShowLibraryItems(connectionParameters, library); + + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } + + return await ScanLibrary( + televisionRepository, + connectionParameters, + library, + getLocalPath, + ffmpegPath, + ffprobePath, + entries.RightToSeq().Flatten().ToList(), + deepScan, + cancellationToken); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return new ScanCanceled(); + } + finally + { + _searchIndex.Commit(); + } + } + + protected abstract Task>> GetShowLibraryItems( + TConnectionParameters connectionParameters, + TLibrary library); + + protected abstract string MediaServerItemId(TShow show); + protected abstract string MediaServerItemId(TSeason season); + protected abstract string MediaServerItemId(TEpisode episode); + protected abstract string MediaServerEtag(TShow show); + protected abstract string MediaServerEtag(TSeason season); + protected abstract string MediaServerEtag(TEpisode episode); + + private async Task> ScanLibrary( + IMediaServerTelevisionRepository televisionRepository, + TConnectionParameters connectionParameters, + TLibrary library, + Func getLocalPath, + string ffmpegPath, + string ffprobePath, + List showEntries, + bool deepScan, + CancellationToken cancellationToken) + { + List existingShows = await televisionRepository.GetExistingShows(library); + + var sortedShows = showEntries.OrderBy(s => s.ShowMetadata.Head().SortTitle).ToList(); + foreach (TShow incoming in showEntries) + { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + + decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / sortedShows.Count; + await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken); + + Either> maybeShow = await televisionRepository + .GetOrAdd(library, incoming) + .BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)); + + if (maybeShow.IsLeft) + { + foreach (BaseError error in maybeShow.LeftToSeq()) + { + _logger.LogWarning( + "Error processing show {Title}: {Error}", + incoming.ShowMetadata.Head().Title, + error.Value); + } + + continue; + } + + foreach (MediaItemScanResult result in maybeShow.RightToSeq()) + { + Either> entries = await GetSeasonLibraryItems( + library, + connectionParameters, + result.Item); + + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } + + Either scanResult = await ScanSeasons( + televisionRepository, + library, + getLocalPath, + result.Item, + connectionParameters, + ffmpegPath, + ffprobePath, + entries.RightToSeq().Flatten().ToList(), + deepScan, + cancellationToken); + + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming)); + + if (result.IsAdded || result.IsUpdated) + { + await _searchIndex.RebuildItems(_searchRepository, new List { result.Item.Id }); + } + } + } + + // trash shows that are no longer present on the media server + var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId) + .Except(showEntries.Map(MediaServerItemId)).ToList(); + List ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds); + await _searchIndex.RebuildItems(_searchRepository, ids); + + await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); + + return Unit.Default; + } + + protected abstract Task>> GetSeasonLibraryItems( + TLibrary library, + TConnectionParameters connectionParameters, + TShow show); + + protected abstract Task>> GetEpisodeLibraryItems( + TLibrary library, + TConnectionParameters connectionParameters, + TSeason season); + + protected abstract Task> GetFullMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TShow incoming, + bool deepScan); + + protected abstract Task> GetFullMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TSeason incoming, + bool deepScan); + + protected abstract Task> GetFullMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TEpisode incoming, + bool deepScan); + + protected abstract Task>> UpdateMetadata( + MediaItemScanResult result, + ShowMetadata fullMetadata); + + protected abstract Task>> UpdateMetadata( + MediaItemScanResult result, + SeasonMetadata fullMetadata); + + protected abstract Task>> UpdateMetadata( + MediaItemScanResult result, + EpisodeMetadata fullMetadata); + + private async Task> ScanSeasons( + IMediaServerTelevisionRepository televisionRepository, + TLibrary library, + Func getLocalPath, + TShow show, + TConnectionParameters connectionParameters, + string ffmpegPath, + string ffprobePath, + List seasonEntries, + bool deepScan, + CancellationToken cancellationToken) + { + List existingSeasons = await televisionRepository.GetExistingSeasons(library, show); + + var sortedSeasons = seasonEntries.OrderBy(s => s.SeasonNumber).ToList(); + foreach (TSeason incoming in sortedSeasons) + { + incoming.ShowId = show.Id; + + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + + Either> maybeSeason = await televisionRepository + .GetOrAdd(library, incoming) + .BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)); + + if (maybeSeason.IsLeft) + { + foreach (BaseError error in maybeSeason.LeftToSeq()) + { + _logger.LogWarning( + "Error processing show {Title} season {SeasonNumber}: {Error}", + show.ShowMetadata.Head().Title, + incoming.SeasonNumber, + error.Value); + } + + continue; + } + + foreach (MediaItemScanResult result in maybeSeason.RightToSeq()) + { + Either> entries = await GetEpisodeLibraryItems( + library, + connectionParameters, + result.Item); + + foreach (BaseError error in entries.LeftToSeq()) + { + return error; + } + + Either scanResult = await ScanEpisodes( + televisionRepository, + library, + getLocalPath, + show, + result.Item, + connectionParameters, + ffmpegPath, + ffprobePath, + entries.RightToSeq().Flatten().ToList(), + deepScan, + cancellationToken); + + foreach (ScanCanceled error in scanResult.LeftToSeq().OfType()) + { + return error; + } + + await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming)); + + result.Item.Show = show; + + if (result.IsAdded || result.IsUpdated) + { + await _searchIndex.RebuildItems(_searchRepository, new List { result.Item.Id }); + } + } + } + + // trash seasons that are no longer present on the media server + var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId) + .Except(seasonEntries.Map(MediaServerItemId)).ToList(); + List ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds); + await _searchIndex.RebuildItems(_searchRepository, ids); + + return Unit.Default; + } + + private async Task> ScanEpisodes( + IMediaServerTelevisionRepository televisionRepository, + TLibrary library, + Func getLocalPath, + TShow show, + TSeason season, + TConnectionParameters connectionParameters, + string ffmpegPath, + string ffprobePath, + List episodeEntries, + bool deepScan, + CancellationToken cancellationToken) + { + List existingEpisodes = await televisionRepository.GetExistingEpisodes(library, season); + + var sortedEpisodes = episodeEntries.OrderBy(s => s.EpisodeMetadata.Head().EpisodeNumber).ToList(); + foreach (TEpisode incoming in sortedEpisodes) + { + if (cancellationToken.IsCancellationRequested) + { + return new ScanCanceled(); + } + + string localPath = getLocalPath(incoming); + if (await ShouldScanItem( + televisionRepository, + library, + show, + season, + existingEpisodes, + incoming, + localPath, + deepScan) == false) + { + continue; + } + + incoming.SeasonId = season.Id; + + Either> maybeEpisode = await televisionRepository + .GetOrAdd(library, incoming) + .MapT( + result => + { + result.LocalPath = localPath; + return result; + }) + .BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan)) + .BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath)) + .BindT(UpdateSubtitles); + + if (maybeEpisode.IsLeft) + { + foreach (BaseError error in maybeEpisode.LeftToSeq()) + { + _logger.LogWarning( + "Error processing episode {Title} s{SeasonNumber:00}e{EpisodeNumber:00}: {Error}", + show.ShowMetadata.Head().Title, + season.SeasonNumber, + incoming.EpisodeMetadata.Head().EpisodeNumber, + error.Value); + } + + continue; + } + + foreach (MediaItemScanResult result in maybeEpisode.RightToSeq()) + { + await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming)); + + if (_localFileSystem.FileExists(result.LocalPath)) + { + if (await televisionRepository.FlagNormal(library, result.Item)) + { + result.IsUpdated = true; + } + } + else + { + Option flagResult = await televisionRepository.FlagUnavailable(library, result.Item); + if (flagResult.IsSome) + { + result.IsUpdated = true; + } + } + + if (result.IsAdded || result.IsUpdated) + { + await _searchIndex.RebuildItems(_searchRepository, new List { result.Item.Id }); + } + } + } + + // trash episodes that are no longer present on the media server + var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId) + .Except(episodeEntries.Map(MediaServerItemId)).ToList(); + List ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds); + await _searchIndex.RebuildItems(_searchRepository, ids); + + return Unit.Default; + } + + private async Task ShouldScanItem( + IMediaServerTelevisionRepository televisionRepository, + TLibrary library, + Show show, + Season season, + List existingEpisodes, + TEpisode incoming, + string localPath, + bool deepScan) + { + // deep scan will always pull every episode + if (deepScan) + { + return true; + } + + Option maybeExisting = existingEpisodes.Find(m => m.MediaServerItemId == MediaServerItemId(incoming)); + string existingItemId = await maybeExisting.Map(e => e.MediaServerItemId).IfNoneAsync(string.Empty); + MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal); + + if (existingState == MediaItemState.Unavailable) + { + // skip scanning unavailable items that still don't exist locally + if (!_localFileSystem.FileExists(localPath)) + { + return false; + } + } + else if (existingItemId == MediaServerItemId(incoming)) + { + // item is unchanged, but file does not exist + // don't scan, but mark as unavailable + if (!_localFileSystem.FileExists(localPath)) + { + foreach (int id in await televisionRepository.FlagUnavailable(library, incoming)) + { + await _searchIndex.RebuildItems(_searchRepository, new List { id }); + } + } + + return false; + } + + if (maybeExisting.IsNone) + { + _logger.LogDebug( + "INSERT: new episode {Show} s{SeasonNumber:00}e{EpisodeNumber:00}", + show.ShowMetadata.Head().Title, + season.SeasonNumber, + incoming.EpisodeMetadata.Head().EpisodeNumber); + } + else + { + _logger.LogDebug( + "UPDATE: Etag has changed for episode {Show} s{SeasonNumber:00}e{EpisodeNumber:00}", + show.ShowMetadata.Head().Title, + season.SeasonNumber, + incoming.EpisodeMetadata.Head().EpisodeNumber); + } + + return true; + } + + private async Task>> UpdateMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TShow incoming, + bool deepScan) + { + foreach (ShowMetadata fullMetadata in await GetFullMetadata( + connectionParameters, + library, + result, + incoming, + deepScan)) + { + // TODO: move some of this code into this scanner + // will have to merge JF, Emby, Plex logic + return await UpdateMetadata(result, fullMetadata); + } + + return result; + } + + private async Task>> UpdateMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TSeason incoming, + bool deepScan) + { + foreach (SeasonMetadata fullMetadata in await GetFullMetadata( + connectionParameters, + library, + result, + incoming, + deepScan)) + { + // TODO: move some of this code into this scanner + // will have to merge JF, Emby, Plex logic + return await UpdateMetadata(result, fullMetadata); + } + + return result; + } + + private async Task>> UpdateMetadata( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TEpisode incoming, + bool deepScan) + { + foreach (EpisodeMetadata fullMetadata in await GetFullMetadata( + connectionParameters, + library, + result, + incoming, + deepScan)) + { + // TODO: move some of this code into this scanner + // will have to merge JF, Emby, Plex logic + return await UpdateMetadata(result, fullMetadata); + } + + return result; + } + + private async Task>> UpdateStatistics( + MediaItemScanResult result, + TEpisode incoming, + string ffmpegPath, + string ffprobePath) + { + TEpisode existing = result.Item; + + if (result.IsAdded || MediaServerItemId(existing) != MediaServerItemId(incoming) || + existing.MediaVersions.Head().Streams.Count == 0) + { + if (_localFileSystem.FileExists(result.LocalPath)) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); + Either refreshResult = + await _localStatisticsProvider.RefreshStatistics( + ffmpegPath, + ffprobePath, + existing, + result.LocalPath); + + foreach (BaseError error in refreshResult.LeftToSeq()) + { + _logger.LogWarning( + "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", + "Statistics", + result.LocalPath, + error.Value); + } + + foreach (bool _ in refreshResult.RightToSeq()) + { + result.IsUpdated = true; + } + } + } + + return result; + } + + private async Task>> UpdateSubtitles( + MediaItemScanResult existing) + { + try + { + // skip checking subtitles for files that don't exist locally + if (!_localFileSystem.FileExists(existing.LocalPath)) + { + return existing; + } + + if (await _localSubtitlesProvider.UpdateSubtitles(existing.Item, existing.LocalPath, false)) + { + return existing; + } + + return BaseError.New("Failed to update local subtitles"); + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } +} diff --git a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs index fb0ee3b9..66f0f8cb 100644 --- a/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs @@ -1,30 +1,25 @@ using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Metadata; -using LanguageExt.UnsafeValueAccess; using MediatR; using Microsoft.Extensions.Logging; namespace ErsatzTV.Core.Plex; -public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner +public class PlexTelevisionLibraryScanner : + MediaServerTelevisionLibraryScanner, IPlexTelevisionLibraryScanner { - private readonly ILocalFileSystem _localFileSystem; - private readonly ILocalStatisticsProvider _localStatisticsProvider; - private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; - private readonly IMediator _mediator; 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; public PlexTelevisionLibraryScanner( @@ -41,20 +36,21 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL ILocalStatisticsProvider localStatisticsProvider, ILocalSubtitlesProvider localSubtitlesProvider, ILogger logger) - : base(metadataRepository, logger) + : base( + localStatisticsProvider, + localSubtitlesProvider, + localFileSystem, + searchRepository, + searchIndex, + mediator, + logger) { _plexServerApiClient = plexServerApiClient; _televisionRepository = televisionRepository; _metadataRepository = metadataRepository; - _searchIndex = searchIndex; - _searchRepository = searchRepository; - _mediator = mediator; _mediaSourceRepository = mediaSourceRepository; _plexPathReplacementService = plexPathReplacementService; _plexTelevisionRepository = plexTelevisionRepository; - _localFileSystem = localFileSystem; - _localStatisticsProvider = localStatisticsProvider; - _localSubtitlesProvider = localSubtitlesProvider; _logger = logger; } @@ -67,805 +63,496 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL bool deepScan, CancellationToken cancellationToken) { - try - { - Either> entries = await _plexServerApiClient.GetShowLibraryContents( - library, - connection, - token); - - foreach (BaseError error in entries.LeftToSeq()) - { - return error; - } + List pathReplacements = + await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId); - 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 + string GetLocalPath(PlexEpisode episode) { - // always commit the search index to prevent corruption - _searchIndex.Commit(); + return _plexPathReplacementService.GetReplacementPlexPath( + pathReplacements, + episode.GetHeadVersion().MediaFiles.Head().Path, + false); } + + return await ScanLibrary( + _plexTelevisionRepository, + new PlexConnectionParameters(connection, token), + library, + GetLocalPath, + ffmpegPath, + ffprobePath, + deepScan, + cancellationToken); } - private async Task> ScanLibrary( - PlexConnection connection, - PlexServerAuthToken token, + // TODO: add or remove metadata? + // private async Task>> UpdateMetadata( + // MediaItemScanResult result, + // PlexEpisode incoming) + // { + // PlexEpisode existing = result.Item; + // + // var toUpdate = existing.EpisodeMetadata + // .Where(em => incoming.EpisodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber)) + // .ToList(); + // var toRemove = existing.EpisodeMetadata.Except(toUpdate).ToList(); + // var toAdd = incoming.EpisodeMetadata + // .Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber)) + // .ToList(); + // + // foreach (EpisodeMetadata metadata in toRemove) + // { + // await _televisionRepository.RemoveMetadata(existing, metadata); + // } + // + // foreach (EpisodeMetadata metadata in toAdd) + // { + // metadata.EpisodeId = existing.Id; + // metadata.Episode = existing; + // existing.EpisodeMetadata.Add(metadata); + // + // await _metadataRepository.Add(metadata); + // } + // + // // TODO: update existing metadata + // + // return result; + // } + + // foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone()) + // { + // foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone()) + // { + // if (incomingFile.Path != existingFile.Path) + // { + // _logger.LogDebug( + // "Plex episode has moved from {OldPath} to {NewPath}", + // existingFile.Path, + // incomingFile.Path); + // + // existingFile.Path = incomingFile.Path; + // + // await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path); + // } + // } + // } + + protected override Task>> GetShowLibraryItems( + PlexConnectionParameters connectionParameters, + PlexLibrary library) => + _plexServerApiClient.GetShowLibraryContents( + library, + connectionParameters.Connection, + connectionParameters.Token); + + protected override Task>> GetSeasonLibraryItems( PlexLibrary library, - string ffmpegPath, - string ffprobePath, - bool deepScan, - List showEntries, - CancellationToken cancellationToken) - { - List existingShows = await _plexTelevisionRepository.GetExistingPlexShows(library); + PlexConnectionParameters connectionParameters, + PlexShow show) => + _plexServerApiClient.GetShowSeasons( + library, + show, + connectionParameters.Connection, + connectionParameters.Token); - List pathReplacements = await _mediaSourceRepository - .GetPlexPathReplacements(library.MediaSourceId); + protected override Task>> GetEpisodeLibraryItems( + PlexLibrary library, + PlexConnectionParameters connectionParameters, + PlexSeason season) => + _plexServerApiClient.GetSeasonEpisodes( + library, + season, + connectionParameters.Connection, + connectionParameters.Token); - foreach (PlexShow incoming in showEntries) + protected override async Task> GetFullMetadata( + PlexConnectionParameters connectionParameters, + PlexLibrary library, + MediaItemScanResult result, + PlexShow incoming, + bool deepScan) + { + if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan) { - if (cancellationToken.IsCancellationRequested) - { - 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)); + Either maybeMetadata = await _plexServerApiClient.GetShowMetadata( + library, + incoming.Key.Replace("/children", string.Empty).Split("/").Last(), + connectionParameters.Connection, + connectionParameters.Token); - if (maybeShow.IsLeft) + foreach (BaseError error in maybeMetadata.LeftToSeq()) { - foreach (BaseError error in maybeShow.LeftToSeq()) - { - _logger.LogWarning( - "Error processing plex show at {Key}: {Error}", - incoming.Key, - error.Value); - } - - continue; + _logger.LogWarning("Failed to get show metadata from Plex: {Error}", error.ToString()); } - foreach (MediaItemScanResult result in maybeShow.RightToSeq()) - { - Either scanResult = await ScanSeasons( - library, - pathReplacements, - result.Item, - connection, - token, - ffmpegPath, - ffprobePath, - deepScan, - cancellationToken); - - 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 }); - } - } + return maybeMetadata.ToOption(); } - // 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); + return None; + } - await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken); + protected override Task> GetFullMetadata( + PlexConnectionParameters connectionParameters, + PlexLibrary library, + MediaItemScanResult result, + PlexSeason incoming, + bool deepScan) + { + if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan) + { + return incoming.SeasonMetadata.HeadOrNone().AsTask(); + } - return Unit.Default; + return Option.None.AsTask(); } - private async Task>> UpdateMetadata( - MediaItemScanResult result, - PlexShow incoming, + protected override async Task> GetFullMetadata( + PlexConnectionParameters connectionParameters, PlexLibrary library, - PlexConnection connection, - PlexServerAuthToken token, + MediaItemScanResult result, + PlexEpisode incoming, bool deepScan) { - PlexShow existing = result.Item; - ShowMetadata existingMetadata = existing.ShowMetadata.Head(); - - if (result.IsAdded || existing.Etag != incoming.Etag || deepScan) + if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan) { - Either maybeMetadata = - await _plexServerApiClient.GetShowMetadata( - library, - incoming.Key.Replace("/children", string.Empty).Split("/").Last(), - connection, - token); - - await maybeMetadata.Match( - async fullMetadata => - { - if (existingMetadata.MetadataKind != MetadataKind.External) - { - existingMetadata.MetadataKind = MetadataKind.External; - await _metadataRepository.MarkAsExternal(existingMetadata); - } - - if (existingMetadata.ContentRating != fullMetadata.ContentRating) - { - existingMetadata.ContentRating = fullMetadata.ContentRating; - await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating); - result.IsUpdated = true; - } - - foreach (Genre genre in existingMetadata.Genres - .Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - existingMetadata.Genres.Remove(genre); - if (await _metadataRepository.RemoveGenre(genre)) - { - result.IsUpdated = true; - } - } - - foreach (Genre genre in fullMetadata.Genres - .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - existingMetadata.Genres.Add(genre); - if (await _televisionRepository.AddGenre(existingMetadata, genre)) - { - result.IsUpdated = true; - } - } - - foreach (Studio studio in existingMetadata.Studios - .Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name)) - .ToList()) - { - existingMetadata.Studios.Remove(studio); - if (await _metadataRepository.RemoveStudio(studio)) - { - result.IsUpdated = true; - } - } - - foreach (Studio studio in fullMetadata.Studios - .Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name)) - .ToList()) - { - existingMetadata.Studios.Add(studio); - if (await _televisionRepository.AddStudio(existingMetadata, studio)) - { - result.IsUpdated = true; - } - } - - foreach (Actor actor in existingMetadata.Actors - .Filter( - a => fullMetadata.Actors.All( - a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) - .ToList()) - { - existingMetadata.Actors.Remove(actor); - if (await _metadataRepository.RemoveActor(actor)) - { - result.IsUpdated = true; - } - } - - foreach (Actor actor in fullMetadata.Actors - .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) - .ToList()) - { - existingMetadata.Actors.Add(actor); - if (await _televisionRepository.AddActor(existingMetadata, actor)) - { - result.IsUpdated = true; - } - } - - foreach (MetadataGuid guid in existingMetadata.Guids - .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - existingMetadata.Guids.Remove(guid); - if (await _metadataRepository.RemoveGuid(guid)) - { - result.IsUpdated = true; - } - } - - foreach (MetadataGuid guid in fullMetadata.Guids - .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - existingMetadata.Guids.Add(guid); - if (await _metadataRepository.AddGuid(existingMetadata, guid)) - { - result.IsUpdated = true; - } - } - - foreach (Tag tag in existingMetadata.Tags - .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - existingMetadata.Tags.Remove(tag); - if (await _metadataRepository.RemoveTag(tag)) - { - result.IsUpdated = true; - } - } - - foreach (Tag tag in fullMetadata.Tags - .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - existingMetadata.Tags.Add(tag); - if (await _televisionRepository.AddTag(existingMetadata, tag)) - { - result.IsUpdated = true; - } - } - - if (result.IsUpdated) - { - await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); - } - }, - _ => Task.CompletedTask); + Either maybeMetadata = + await _plexServerApiClient.GetEpisodeMetadataAndStatistics( + library, + incoming.Key.Split("/").Last(), + connectionParameters.Connection, + connectionParameters.Token) + .MapT(tuple => tuple.Item1); // drop the statistics part from plex, we scan locally + + foreach (BaseError error in maybeMetadata.LeftToSeq()) + { + _logger.LogWarning("Failed to get episode metadata from Plex: {Error}", error.ToString()); + } + + return maybeMetadata.ToOption(); } - return result; + return None; } - private async Task>> UpdateArtwork( + protected override async Task>> UpdateMetadata( MediaItemScanResult result, - PlexShow incoming) + ShowMetadata fullMetadata) { PlexShow existing = result.Item; ShowMetadata existingMetadata = existing.ShowMetadata.Head(); - ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); - bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); - bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); - if (poster || fanArt) + if (existingMetadata.MetadataKind != MetadataKind.External) { - await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); + existingMetadata.MetadataKind = MetadataKind.External; + await _metadataRepository.MarkAsExternal(existingMetadata); } - return result; - } - - private async Task> ScanSeasons( - PlexLibrary library, - List pathReplacements, - PlexShow show, - PlexConnection connection, - PlexServerAuthToken token, - string ffmpegPath, - string ffprobePath, - bool deepScan, - CancellationToken cancellationToken) - { - List existingSeasons = await _plexTelevisionRepository.GetExistingPlexSeasons(library, show); - - Either> entries = await _plexServerApiClient.GetShowSeasons( - library, - show, - connection, - token); - - foreach (BaseError error in entries.LeftToSeq()) + if (existingMetadata.ContentRating != fullMetadata.ContentRating) { - return error; + existingMetadata.ContentRating = fullMetadata.ContentRating; + await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating); + result.IsUpdated = true; } - var seasonEntries = entries.RightToSeq().Flatten().ToList(); - foreach (PlexSeason incoming in seasonEntries) + foreach (Genre genre in existingMetadata.Genres + .Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) { - incoming.ShowId = show.Id; - - // TODO: figure out how to rebuild playlists - Either maybeSeason = await _televisionRepository - .GetOrAddPlexSeason(library, incoming) - .BindT(existing => UpdateMetadataAndArtwork(existing, incoming, deepScan)); - - foreach (BaseError error in maybeSeason.LeftToSeq()) - { - _logger.LogWarning( - "Error processing plex season at {Key}: {Error}", - incoming.Key, - error.Value); - - return error; - } - - foreach (PlexSeason season in maybeSeason.RightToSeq()) + existingMetadata.Genres.Remove(genre); + if (await _metadataRepository.RemoveGenre(genre)) { - 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 }); + result.IsUpdated = true; } } - 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, - bool deepScan) - { - SeasonMetadata existingMetadata = existing.SeasonMetadata.Head(); - SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head(); - - if (existing.Etag != incoming.Etag || deepScan) + foreach (Genre genre in fullMetadata.Genres + .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) { - foreach (MetadataGuid guid in existingMetadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) + existingMetadata.Genres.Add(genre); + if (await _televisionRepository.AddGenre(existingMetadata, genre)) { - existingMetadata.Guids.Remove(guid); - await _metadataRepository.RemoveGuid(guid); + result.IsUpdated = true; } + } - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) + foreach (Studio studio in existingMetadata.Studios + .Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name)) + .ToList()) + { + existingMetadata.Studios.Remove(studio); + if (await _metadataRepository.RemoveStudio(studio)) { - existingMetadata.Guids.Add(guid); - await _metadataRepository.AddGuid(existingMetadata, guid); + result.IsUpdated = true; } + } - foreach (Tag tag in existingMetadata.Tags - .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Studio studio in fullMetadata.Studios + .Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name)) + .ToList()) + { + existingMetadata.Studios.Add(studio); + if (await _televisionRepository.AddStudio(existingMetadata, studio)) { - existingMetadata.Tags.Remove(tag); - await _metadataRepository.RemoveTag(tag); + result.IsUpdated = true; } + } - foreach (Tag tag in incomingMetadata.Tags - .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) + foreach (Actor actor in existingMetadata.Actors + .Filter( + a => fullMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + existingMetadata.Actors.Remove(actor); + if (await _metadataRepository.RemoveActor(actor)) { - existingMetadata.Tags.Add(tag); - await _televisionRepository.AddTag(existingMetadata, tag); + result.IsUpdated = true; } - - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); - await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); } - return existing; - } - - private async Task> ScanEpisodes( - PlexLibrary library, - List pathReplacements, - PlexSeason season, - PlexConnection connection, - PlexServerAuthToken token, - string ffmpegPath, - string ffprobePath, - bool deepScan, - CancellationToken cancellationToken) - { - List existingEpisodes = await _plexTelevisionRepository.GetExistingPlexEpisodes(library, season); - - Either> entries = await _plexServerApiClient.GetSeasonEpisodes( - library, - season, - connection, - token); - - foreach (BaseError error in entries.LeftToSeq()) + foreach (Actor actor in fullMetadata.Actors + .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) { - return error; + existingMetadata.Actors.Add(actor); + if (await _televisionRepository.AddActor(existingMetadata, actor)) + { + result.IsUpdated = true; + } } - var episodeEntries = entries.RightToSeq().Flatten().ToList(); - foreach (PlexEpisode incoming in episodeEntries) + foreach (MetadataGuid guid in existingMetadata.Guids + .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) { - if (cancellationToken.IsCancellationRequested) + existingMetadata.Guids.Remove(guid); + if (await _metadataRepository.RemoveGuid(guid)) { - return new ScanCanceled(); + result.IsUpdated = true; } + } - if (await ShouldScanItem(library, pathReplacements, existingEpisodes, incoming, deepScan) == false) + foreach (MetadataGuid guid in fullMetadata.Guids + .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Add(guid); + if (await _metadataRepository.AddGuid(existingMetadata, guid)) { - continue; + result.IsUpdated = true; } + } - 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)); - - foreach (BaseError error in maybeEpisode.LeftToSeq()) + foreach (Tag tag in existingMetadata.Tags + .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Remove(tag); + if (await _metadataRepository.RemoveTag(tag)) { - switch (error) - { - case ScanCanceled: - return error; - default: - _logger.LogWarning( - "Error processing plex episode at {Key}: {Error}", - incoming.Key, - error.Value); - break; - } + result.IsUpdated = true; } + } - foreach (MediaItemScanResult result in maybeEpisode.RightToSeq()) + foreach (Tag tag in fullMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Add(tag); + if (await _televisionRepository.AddTag(existingMetadata, tag)) { - 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 }); - } + result.IsUpdated = true; } } - 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); + bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster); + bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt); + if (poster || fanArt) + { + result.IsUpdated = true; + } - _searchIndex.Commit(); + if (result.IsUpdated) + { + await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); + } - return Unit.Default; + return result; } - private async Task ShouldScanItem( - PlexLibrary library, - List pathReplacements, - List existingEpisodes, - PlexEpisode incoming, - bool deepScan) + protected override async Task>> UpdateMetadata( + MediaItemScanResult result, + SeasonMetadata fullMetadata) { - // 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); + PlexSeason existing = result.Item; + SeasonMetadata existingMetadata = existing.SeasonMetadata.Head(); - // if media is unavailable, only scan if file now exists - if (existingState == MediaItemState.Unavailable) + foreach (MetadataGuid guid in existingMetadata.Guids + .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Remove(guid); + if (await _metadataRepository.RemoveGuid(guid)) { - if (!_localFileSystem.FileExists(localPath)) - { - return false; - } + result.IsUpdated = true; } - else if (existingTag == incoming.Etag) - { - if (!_localFileSystem.FileExists(localPath)) - { - foreach (int id in await _plexTelevisionRepository.FlagUnavailable(library, incoming)) - { - await _searchIndex.RebuildItems(_searchRepository, new List { id }); - } - } + } - // _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key); - return false; + foreach (MetadataGuid guid in fullMetadata.Guids + .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Add(guid); + if (await _metadataRepository.AddGuid(existingMetadata, guid)) + { + result.IsUpdated = true; } - - // _logger.LogDebug( - // "UPDATE: Etag has changed for episode {Episode}", - // $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}"); } - return true; - } - - private async Task>> UpdateMetadata( - MediaItemScanResult result, - PlexEpisode incoming) - { - PlexEpisode existing = result.Item; - - var toUpdate = existing.EpisodeMetadata - .Where(em => incoming.EpisodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber)) - .ToList(); - var toRemove = existing.EpisodeMetadata.Except(toUpdate).ToList(); - var toAdd = incoming.EpisodeMetadata - .Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber)) - .ToList(); - - foreach (EpisodeMetadata metadata in toRemove) + foreach (Tag tag in existingMetadata.Tags + .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) { - await _televisionRepository.RemoveMetadata(existing, metadata); + existingMetadata.Tags.Remove(tag); + if (await _metadataRepository.RemoveTag(tag)) + { + result.IsUpdated = true; + } } - foreach (EpisodeMetadata metadata in toAdd) + foreach (Tag tag in fullMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) { - metadata.EpisodeId = existing.Id; - metadata.Episode = existing; - existing.EpisodeMetadata.Add(metadata); + existingMetadata.Tags.Add(tag); + if (await _televisionRepository.AddTag(existingMetadata, tag)) + { + result.IsUpdated = true; + } + } - await _metadataRepository.Add(metadata); + if (await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster)) + { + result.IsUpdated = true; } - // TODO: update existing metadata + await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); return result; } - private async Task>> UpdateStatistics( - List pathReplacements, + protected override async Task>> UpdateMetadata( MediaItemScanResult result, - PlexEpisode incoming, - PlexLibrary library, - PlexConnection connection, - PlexServerAuthToken token, - string ffmpegPath, - string ffprobePath, - bool deepScan) + EpisodeMetadata fullMetadata) { PlexEpisode existing = result.Item; - MediaVersion existingVersion = existing.MediaVersions.Head(); - MediaVersion incomingVersion = incoming.MediaVersions.Head(); + EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head(); - if (result.IsAdded || existing.Etag != incoming.Etag || deepScan || existingVersion.Streams.Count == 0) + foreach (MetadataGuid guid in existingMetadata.Guids + .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) { - foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone()) + existingMetadata.Guids.Remove(guid); + if (await _metadataRepository.RemoveGuid(guid)) { - foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone()) - { - if (incomingFile.Path != existingFile.Path) - { - _logger.LogDebug( - "Plex episode has moved from {OldPath} to {NewPath}", - existingFile.Path, - incomingFile.Path); - - existingFile.Path = incomingFile.Path; - - await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path); - } - } + result.IsUpdated = true; } + } - Either refreshResult = true; - - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); - - if ((existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0) && - _localFileSystem.FileExists(localPath)) + foreach (MetadataGuid guid in fullMetadata.Guids + .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + existingMetadata.Guids.Add(guid); + if (await _metadataRepository.AddGuid(existingMetadata, guid)) { - _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); - refreshResult = await _localStatisticsProvider.RefreshStatistics( - ffmpegPath, - ffprobePath, - existing, - localPath); + result.IsUpdated = true; } + } - foreach (BaseError error in refreshResult.LeftToSeq()) + foreach (Tag tag in existingMetadata.Tags + .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Remove(tag); + if (await _metadataRepository.RemoveTag(tag)) { - _logger.LogWarning( - "Unable to refresh {Attribute} for media item {Path}. Error: {Error}", - "Statistics", - localPath, - error.Value); + result.IsUpdated = true; } + } - foreach (var _ in refreshResult.RightToSeq()) + foreach (Tag tag in fullMetadata.Tags + .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + existingMetadata.Tags.Add(tag); + if (await _televisionRepository.AddTag(existingMetadata, tag)) { - foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id)) - { - await _searchIndex.UpdateItems( - _searchRepository, - new List { updated }); - } - - Either> maybeStatistics = - await _plexServerApiClient.GetEpisodeMetadataAndStatistics( - library, - incoming.Key.Split("/").Last(), - connection, - token); - - foreach (Tuple tuple in maybeStatistics.RightToSeq()) - { - (EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple; - - Option maybeExisting = existing.EpisodeMetadata - .Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber); - foreach (EpisodeMetadata existingMetadata in maybeExisting) - { - foreach (MetadataGuid guid in existingMetadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - existingMetadata.Guids.Remove(guid); - await _metadataRepository.RemoveGuid(guid); - } - - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - existingMetadata.Guids.Add(guid); - await _metadataRepository.AddGuid(existingMetadata, guid); - } - - foreach (Tag tag in existingMetadata.Tags - .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - existingMetadata.Tags.Remove(tag); - await _metadataRepository.RemoveTag(tag); - } - - foreach (Tag tag in incomingMetadata.Tags - .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - existingMetadata.Tags.Add(tag); - await _televisionRepository.AddTag(existingMetadata, tag); - } - } - - existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio; - existingVersion.VideoScanKind = mediaVersion.VideoScanKind; - existingVersion.DateUpdated = mediaVersion.DateUpdated; - - await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion); - } + result.IsUpdated = true; } } + if (await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Thumbnail)) + { + result.IsUpdated = true; + } + + await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); + return result; } - private async Task>> UpdateSubtitles( - List pathReplacements, - MediaItemScanResult result, - PlexEpisode incoming) + protected override string MediaServerItemId(PlexShow show) => show.Key; + protected override string MediaServerItemId(PlexSeason season) => season.Key; + protected override string MediaServerItemId(PlexEpisode episode) => episode.Key; + + protected override string MediaServerEtag(PlexShow show) => show.Etag; + protected override string MediaServerEtag(PlexSeason season) => season.Etag; + protected override string MediaServerEtag(PlexEpisode episode) => episode.Etag; + + private async Task UpdateArtworkIfNeeded( + Domain.Metadata existingMetadata, + Domain.Metadata incomingMetadata, + ArtworkKind artworkKind) { - try + if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated) { - PlexEpisode existing = result.Item; + Option maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); - string localPath = _plexPathReplacementService.GetReplacementPlexPath( - pathReplacements, - incoming.MediaVersions.Head().MediaFiles.Head().Path, - false); + if (maybeIncomingArtwork.IsNone) + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind); + await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind); + } - await _localSubtitlesProvider.UpdateSubtitles(existing, localPath, false); + foreach (Artwork incomingArtwork in maybeIncomingArtwork) + { + _logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path); - return result; - } - catch (Exception ex) - { - return BaseError.New(ex.ToString()); - } - } + Option maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten() + .Find(a => a.ArtworkKind == artworkKind); - private async Task>> UpdateArtwork( - MediaItemScanResult result, - PlexEpisode incoming) - { - PlexEpisode existing = result.Item; - foreach (EpisodeMetadata incomingMetadata in incoming.EpisodeMetadata) - { - Option maybeExistingMetadata = existing.EpisodeMetadata - .Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber); - if (maybeExistingMetadata.IsSome) - { - EpisodeMetadata existingMetadata = maybeExistingMetadata.ValueUnsafe(); - await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail); - await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); + if (maybeExistingArtwork.IsNone) + { + existingMetadata.Artwork ??= new List(); + existingMetadata.Artwork.Add(incomingArtwork); + await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork); + } + + foreach (Artwork existingArtwork in maybeExistingArtwork) + { + existingArtwork.Path = incomingArtwork.Path; + existingArtwork.DateUpdated = incomingArtwork.DateUpdated; + await _metadataRepository.UpdateArtworkPath(existingArtwork); + } } + + return true; } - return result; + return false; } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs index dc24199c..fb6e5c46 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs @@ -1,8 +1,10 @@ using Dapper; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Interfaces.Repositories; -using LanguageExt.UnsafeValueAccess; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data.Repositories; @@ -18,7 +20,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( - @"SELECT ItemId, Etag FROM EmbyShow + @"SELECT ItemId, Etag, MI.State FROM EmbyShow INNER JOIN Show S on EmbyShow.Id = S.Id INNER JOIN MediaItem MI on S.Id = MI.Id INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id @@ -27,51 +29,37 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository .Map(result => result.ToList()); } - public async Task> GetExistingSeasons(EmbyLibrary library, string showItemId) + public async Task> GetExistingSeasons(EmbyLibrary library, EmbyShow show) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( - @"SELECT EmbySeason.ItemId, EmbySeason.Etag FROM EmbySeason + @"SELECT EmbySeason.ItemId, EmbySeason.Etag, MI.State FROM EmbySeason INNER JOIN Season S on EmbySeason.Id = S.Id INNER JOIN MediaItem MI on S.Id = MI.Id INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id INNER JOIN Show S2 on S.ShowId = S2.Id INNER JOIN EmbyShow JS on S2.Id = JS.Id WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @ShowItemId", - new { LibraryId = library.Id, ShowItemId = showItemId }) + new { LibraryId = library.Id, ShowItemId = show.ItemId }) .Map(result => result.ToList()); } - public async Task> GetExistingEpisodes(EmbyLibrary library, string seasonItemId) + public async Task> GetExistingEpisodes(EmbyLibrary library, EmbySeason season) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( - @"SELECT EmbyEpisode.ItemId, EmbyEpisode.Etag FROM EmbyEpisode + @"SELECT EmbyEpisode.ItemId, EmbyEpisode.Etag, MI.State FROM EmbyEpisode INNER JOIN Episode E on EmbyEpisode.Id = E.Id INNER JOIN MediaItem MI on E.Id = MI.Id INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id INNER JOIN Season S2 on E.SeasonId = S2.Id INNER JOIN EmbySeason JS on S2.Id = JS.Id WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @SeasonItemId", - new { LibraryId = library.Id, SeasonItemId = seasonItemId }) + new { LibraryId = library.Id, SeasonItemId = season.ItemId }) .Map(result => result.ToList()); } - public async Task AddShow(EmbyShow show) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - await dbContext.AddAsync(show); - if (await dbContext.SaveChangesAsync() <= 0) - { - return false; - } - - await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return true; - } - - public async Task> Update(EmbyShow show) + public async Task>> GetOrAdd(EmbyLibrary library, EmbyShow item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.EmbyShows @@ -91,188 +79,24 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository .ThenInclude(mm => mm.Guids) .Include(m => m.TraktListItems) .ThenInclude(tli => tli.TraktList) - .Filter(m => m.ItemId == show.ItemId) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - EmbyShow existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - show.LibraryPath = existing.LibraryPath; - show.Id = existing.Id; - - existing.Etag = show.Etag; - - // metadata - ShowMetadata metadata = existing.ShowMetadata.Head(); - ShowMetadata incomingMetadata = show.ShowMetadata.Head(); - metadata.MetadataKind = incomingMetadata.MetadataKind; - metadata.ContentRating = incomingMetadata.ContentRating; - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Plot = incomingMetadata.Plot; - metadata.Year = incomingMetadata.Year; - metadata.Tagline = incomingMetadata.Tagline; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - - // genres - foreach (Genre genre in metadata.Genres - .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Remove(genre); - } - - foreach (Genre genre in incomingMetadata.Genres - .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Add(genre); - } - - // tags - foreach (Tag tag in metadata.Tags - .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .Filter(g => g.ExternalCollectionId is null) - .ToList()) - { - metadata.Tags.Remove(tag); - } + .SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId); - foreach (Tag tag in incomingMetadata.Tags - .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Tags.Add(tag); - } - - // studios - foreach (Studio studio in metadata.Studios - .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Remove(studio); - } - - foreach (Studio studio in incomingMetadata.Studios - .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Add(studio); - } - - // actors - foreach (Actor actor in metadata.Actors - .Filter( - a => incomingMetadata.Actors.All( - a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) - .ToList()) - { - metadata.Actors.Remove(actor); - } - - foreach (Actor actor in incomingMetadata.Actors - .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) - .ToList()) - { - metadata.Actors.Add(actor); - } - - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } - - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } - - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - - // poster - Artwork incomingPoster = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (incomingPoster != null) - { - Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (poster == null) - { - poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; - metadata.Artwork.Add(poster); - } - - poster.Path = incomingPoster.Path; - poster.DateAdded = incomingPoster.DateAdded; - poster.DateUpdated = incomingPoster.DateUpdated; - } - - // fan art - Artwork incomingFanArt = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (incomingFanArt != null) - { - Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (fanArt == null) - { - fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; - metadata.Artwork.Add(fanArt); - } - - fanArt.Path = incomingFanArt.Path; - fanArt.DateAdded = incomingFanArt.DateAdded; - fanArt.DateUpdated = incomingFanArt.DateUpdated; - } - - var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); - foreach (Artwork artworkToRemove in metadata.Artwork - .Filter(a => !paths.Contains(a.Path)) - .ToList()) - { - metadata.Artwork.Remove(artworkToRemove); - } - } - - await dbContext.SaveChangesAsync(); - - return maybeExisting; - } - - public async Task AddSeason(EmbyShow show, EmbySeason season) - { - try + foreach (EmbyShow embyShow in maybeExisting) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - season.ShowId = await dbContext.Connection.ExecuteScalarAsync( - @"SELECT Id FROM EmbyShow WHERE ItemId = @ItemId", - new { show.ItemId }); - - await dbContext.AddAsync(season); - if (await dbContext.SaveChangesAsync() <= 0) + var result = new MediaItemScanResult(embyShow) { IsAdded = false }; + if (embyShow.Etag != item.Etag) { - return false; + await UpdateShow(dbContext, embyShow, item); + result.IsUpdated = true; } - await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return true; - } - catch (Exception) - { - return false; + return result; } + + return await AddShow(dbContext, library, item); } - public async Task> Update(EmbySeason season) + public async Task>> GetOrAdd(EmbyLibrary library, EmbySeason item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.EmbySeasons @@ -281,139 +105,26 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository .ThenInclude(mm => mm.Artwork) .Include(m => m.SeasonMetadata) .ThenInclude(mm => mm.Guids) - .Filter(m => m.ItemId == season.ItemId) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - EmbySeason existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - season.LibraryPath = existing.LibraryPath; - season.Id = existing.Id; - - existing.Etag = season.Etag; - existing.SeasonNumber = season.SeasonNumber; - - // metadata - SeasonMetadata metadata = existing.SeasonMetadata.Head(); - SeasonMetadata incomingMetadata = season.SeasonMetadata.Head(); - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Year = incomingMetadata.Year; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - - // poster - Artwork incomingPoster = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (incomingPoster != null) - { - Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (poster == null) - { - poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; - metadata.Artwork.Add(poster); - } - - poster.Path = incomingPoster.Path; - poster.DateAdded = incomingPoster.DateAdded; - poster.DateUpdated = incomingPoster.DateUpdated; - } + .SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId); - // thumbnail - Artwork incomingThumbnail = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (incomingThumbnail != null) - { - Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (thumb == null) - { - thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; - metadata.Artwork.Add(thumb); - } - - thumb.Path = incomingThumbnail.Path; - thumb.DateAdded = incomingThumbnail.DateAdded; - thumb.DateUpdated = incomingThumbnail.DateUpdated; - } - - // fan art - Artwork incomingFanArt = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (incomingFanArt != null) - { - Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (fanArt == null) - { - fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; - metadata.Artwork.Add(fanArt); - } - - fanArt.Path = incomingFanArt.Path; - fanArt.DateAdded = incomingFanArt.DateAdded; - fanArt.DateUpdated = incomingFanArt.DateUpdated; - } - - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } - - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } - - var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); - foreach (Artwork artworkToRemove in metadata.Artwork - .Filter(a => !paths.Contains(a.Path)) - .ToList()) - { - metadata.Artwork.Remove(artworkToRemove); - } - } - - await dbContext.SaveChangesAsync(); - - return maybeExisting; - } - - public async Task AddEpisode(EmbySeason season, EmbyEpisode episode) - { - try + foreach (EmbySeason embySeason in maybeExisting) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - episode.SeasonId = await dbContext.Connection.ExecuteScalarAsync( - @"SELECT Id FROM EmbySeason WHERE ItemId = @ItemId", - new { season.ItemId }); - - await dbContext.AddAsync(episode); - if (await dbContext.SaveChangesAsync() <= 0) + var result = new MediaItemScanResult(embySeason) { IsAdded = false }; + if (embySeason.Etag != item.Etag) { - return false; + await UpdateSeason(dbContext, embySeason, item); + result.IsUpdated = true; } - await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync(); - return true; - } - catch (Exception) - { - return false; + return result; } + + return await AddSeason(dbContext, library, item); } - public async Task> Update(EmbyEpisode episode) + public async Task>> GetOrAdd( + EmbyLibrary library, + EmbyEpisode item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.EmbyEpisodes @@ -442,190 +153,574 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository .Include(m => m.Season) .Include(m => m.TraktListItems) .ThenInclude(tli => tli.TraktList) - .Filter(m => m.ItemId == episode.ItemId) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - EmbyEpisode existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - episode.LibraryPath = existing.LibraryPath; - episode.Id = existing.Id; - - existing.Etag = episode.Etag; - - // metadata - // TODO: multiple metadata? - EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); - EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head(); - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Plot = incomingMetadata.Plot; - metadata.Year = incomingMetadata.Year; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - metadata.EpisodeNumber = incomingMetadata.EpisodeNumber; - - // thumbnail - Artwork incomingThumbnail = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (incomingThumbnail != null) - { - Artwork thumbnail = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (thumbnail == null) - { - thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; - metadata.Artwork.Add(thumbnail); - } - - thumbnail.Path = incomingThumbnail.Path; - thumbnail.DateAdded = incomingThumbnail.DateAdded; - thumbnail.DateUpdated = incomingThumbnail.DateUpdated; - } - - // directors - foreach (Director director in metadata.Directors - .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Remove(director); - } - - foreach (Director director in incomingMetadata.Directors - .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Add(director); - } + .SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId); - // writers - foreach (Writer writer in metadata.Writers - .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) + foreach (EmbyEpisode embyEpisode in maybeExisting) + { + var result = new MediaItemScanResult(embyEpisode) { IsAdded = false }; + if (embyEpisode.Etag != item.Etag) { - metadata.Writers.Remove(writer); + await UpdateEpisode(dbContext, embyEpisode, item); + result.IsUpdated = true; } - foreach (Writer writer in incomingMetadata.Writers - .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) - { - metadata.Writers.Add(writer); - } + return result; + } - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } + return await AddEpisode(dbContext, library, item); + } - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } + public async Task SetEtag(EmbyShow show, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE EmbyShow SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, show.Id }).Map(_ => Unit.Default); + } - var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); - foreach (Artwork artworkToRemove in metadata.Artwork - .Filter(a => !paths.Contains(a.Path)) - .ToList()) - { - metadata.Artwork.Remove(artworkToRemove); - } + public async Task SetEtag(EmbySeason season, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE EmbySeason SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, season.Id }).Map(_ => Unit.Default); + } - // version - MediaVersion version = existing.MediaVersions.Head(); - MediaVersion incomingVersion = episode.MediaVersions.Head(); - version.Name = incomingVersion.Name; - version.DateAdded = incomingVersion.DateAdded; + public async Task SetEtag(EmbyEpisode episode, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE EmbyEpisode SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, episode.Id }).Map(_ => Unit.Default); + } - // media file - MediaFile file = version.MediaFiles.Head(); - MediaFile incomingFile = incomingVersion.MediaFiles.Head(); - file.Path = incomingFile.Path; - } + public async Task FlagNormal(EmbyLibrary library, EmbyEpisode episode) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - await dbContext.SaveChangesAsync(); + episode.State = MediaItemState.Normal; - return maybeExisting; + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT EmbyEpisode.Id FROM EmbyEpisode + INNER JOIN MediaItem MI ON MI.Id = EmbyEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbyEpisode.ItemId = @ItemId)", + new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0); } - public async Task> RemoveMissingShows(EmbyLibrary library, List showIds) + public async Task> FlagFileNotFoundShows(EmbyLibrary library, List showItemIds) { + if (showItemIds.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 EmbyShow js ON js.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds", - new { LibraryId = library.Id, ShowIds = showIds }).Map(result => result.ToList()); + @"SELECT M.Id + FROM MediaItem M + INNER JOIN EmbyShow ON EmbyShow.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE EmbyShow.ItemId IN @ShowItemIds", + new { LibraryId = library.Id, ShowItemIds = showItemIds }) + .Map(result => result.ToList()); await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", new { Ids = ids }); return ids; } - public async Task RemoveMissingSeasons(EmbyLibrary library, List seasonIds) + public async Task> FlagFileNotFoundSeasons(EmbyLibrary library, List seasonItemIds) { + if (seasonItemIds.Count == 0) + { + return new List(); + } + 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 EmbySeason js ON js.Id = m.Id - INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id - WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)", - new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN EmbySeason ON EmbySeason.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE EmbySeason.ItemId IN @SeasonItemIds", + new { LibraryId = library.Id, SeasonItemIds = seasonItemIds }) + .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> RemoveMissingEpisodes(EmbyLibrary library, List episodeIds) + public async Task> FlagFileNotFoundEpisodes(EmbyLibrary library, List episodeItemIds) { + if (episodeItemIds.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 EmbyEpisode ee ON ee.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - WHERE lp.LibraryId = @LibraryId AND ee.ItemId IN @EpisodeIds", - new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList()); + @"SELECT M.Id + FROM MediaItem M + INNER JOIN EmbyEpisode ON EmbyEpisode.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE EmbyEpisode.ItemId IN @EpisodeItemIds", + new { LibraryId = library.Id, EpisodeItemIds = episodeItemIds }) + .Map(result => result.ToList()); await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", new { Ids = ids }); return ids; } - public async Task DeleteEmptySeasons(EmbyLibrary library) + public async Task> FlagUnavailable(EmbyLibrary library, EmbyEpisode episode) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - List seasons = await dbContext.EmbySeasons - .Filter(s => s.LibraryPath.LibraryId == library.Id) - .Filter(s => s.Episodes.Count == 0) - .ToListAsync(); - dbContext.Seasons.RemoveRange(seasons); + + episode.State = MediaItemState.Unavailable; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT EmbyEpisode.Id FROM EmbyEpisode + INNER JOIN MediaItem MI ON MI.Id = EmbyEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbyEpisode.ItemId = @ItemId", + new { LibraryId = library.Id, episode.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } + + private async Task UpdateShow(TvContext dbContext, EmbyShow existing, EmbyShow incoming) + { + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + // metadata + ShowMetadata metadata = existing.ShowMetadata.Head(); + ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); + metadata.MetadataKind = incomingMetadata.MetadataKind; + metadata.ContentRating = incomingMetadata.ContentRating; + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.Tagline = incomingMetadata.Tagline; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + + // genres + foreach (Genre genre in metadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Remove(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Add(genre); + } + + // tags + foreach (Tag tag in metadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .Filter(g => g.ExternalCollectionId is null) + .ToList()) + { + metadata.Tags.Remove(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Add(tag); + } + + // studios + foreach (Studio studio in metadata.Studios + .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Remove(studio); + } + + foreach (Studio studio in incomingMetadata.Studios + .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Add(studio); + } + + // actors + foreach (Actor actor in metadata.Actors + .Filter( + a => incomingMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + metadata.Actors.Remove(actor); + } + + foreach (Actor actor in incomingMetadata.Actors + .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + metadata.Actors.Add(actor); + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + + var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); + foreach (Artwork artworkToRemove in metadata.Artwork + .Filter(a => !paths.Contains(a.Path)) + .ToList()) + { + metadata.Artwork.Remove(artworkToRemove); + } + await dbContext.SaveChangesAsync(); - return Unit.Default; } - public async Task> DeleteEmptyShows(EmbyLibrary library) + private async Task UpdateSeason(TvContext dbContext, EmbySeason existing, EmbySeason incoming) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - List shows = await dbContext.EmbyShows - .Filter(s => s.LibraryPath.LibraryId == library.Id) - .Filter(s => s.Seasons.Count == 0) - .ToListAsync(); - var ids = shows.Map(s => s.Id).ToList(); - dbContext.Shows.RemoveRange(shows); + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + existing.SeasonNumber = incoming.SeasonNumber; + + // metadata + SeasonMetadata metadata = existing.SeasonMetadata.Head(); + SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Year = incomingMetadata.Year; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // thumbnail + Artwork incomingThumbnail = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (incomingThumbnail != null) + { + Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (thumb == null) + { + thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; + metadata.Artwork.Add(thumb); + } + + thumb.Path = incomingThumbnail.Path; + thumb.DateAdded = incomingThumbnail.DateAdded; + thumb.DateUpdated = incomingThumbnail.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); + foreach (Artwork artworkToRemove in metadata.Artwork + .Filter(a => !paths.Contains(a.Path)) + .ToList()) + { + metadata.Artwork.Remove(artworkToRemove); + } + await dbContext.SaveChangesAsync(); - return ids; + } + + public async Task UpdateEpisode(TvContext dbContext, EmbyEpisode existing, EmbyEpisode incoming) + { + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + // metadata + // TODO: multiple metadata? + EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); + EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + metadata.EpisodeNumber = incomingMetadata.EpisodeNumber; + + // thumbnail + Artwork incomingThumbnail = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (incomingThumbnail != null) + { + Artwork thumbnail = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (thumbnail == null) + { + thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; + metadata.Artwork.Add(thumbnail); + } + + thumbnail.Path = incomingThumbnail.Path; + thumbnail.DateAdded = incomingThumbnail.DateAdded; + thumbnail.DateUpdated = incomingThumbnail.DateUpdated; + } + + // directors + foreach (Director director in metadata.Directors + .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Remove(director); + } + + foreach (Director director in incomingMetadata.Directors + .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Add(director); + } + + // writers + foreach (Writer writer in metadata.Writers + .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Remove(writer); + } + + foreach (Writer writer in incomingMetadata.Writers + .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Add(writer); + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); + foreach (Artwork artworkToRemove in metadata.Artwork + .Filter(a => !paths.Contains(a.Path)) + .ToList()) + { + metadata.Artwork.Remove(artworkToRemove); + } + + // version + MediaVersion version = existing.MediaVersions.Head(); + MediaVersion incomingVersion = incoming.MediaVersions.Head(); + version.Name = incomingVersion.Name; + version.DateAdded = incomingVersion.DateAdded; + + // media file + MediaFile file = version.MediaFiles.Head(); + MediaFile incomingFile = incomingVersion.MediaFiles.Head(); + file.Path = incomingFile.Path; + + await dbContext.SaveChangesAsync(); + } + + private async Task>> AddShow( + TvContext dbContext, + EmbyLibrary library, + EmbyShow show) + { + try + { + // blank out etag for initial save in case other updates fail + show.Etag = string.Empty; + + show.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(show); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(show) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } + + private async Task>> AddSeason( + TvContext dbContext, + EmbyLibrary library, + EmbySeason season) + { + try + { + // blank out etag for initial save in case other updates fail + season.Etag = string.Empty; + + season.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(season); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(season) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } + + private async Task>> AddEpisode( + TvContext dbContext, + EmbyLibrary library, + EmbyEpisode episode) + { + try + { + // blank out etag for initial save in case other updates fail + episode.Etag = string.Empty; + + episode.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(episode); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(episode) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs index 3c353ab1..7b925b85 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs @@ -1,8 +1,10 @@ using Dapper; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; -using LanguageExt.UnsafeValueAccess; +using ErsatzTV.Core.Metadata; +using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data.Repositories; @@ -18,7 +20,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( - @"SELECT ItemId, Etag FROM JellyfinShow + @"SELECT ItemId, Etag, MI.State FROM JellyfinShow INNER JOIN Show S on JellyfinShow.Id = S.Id INNER JOIN MediaItem MI on S.Id = MI.Id INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id @@ -27,51 +29,39 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository .Map(result => result.ToList()); } - public async Task> GetExistingSeasons(JellyfinLibrary library, string showItemId) + public async Task> GetExistingSeasons(JellyfinLibrary library, JellyfinShow show) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( - @"SELECT JellyfinSeason.ItemId, JellyfinSeason.Etag FROM JellyfinSeason + @"SELECT JellyfinSeason.ItemId, JellyfinSeason.Etag, MI.State FROM JellyfinSeason INNER JOIN Season S on JellyfinSeason.Id = S.Id INNER JOIN MediaItem MI on S.Id = MI.Id INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id INNER JOIN Show S2 on S.ShowId = S2.Id INNER JOIN JellyfinShow JS on S2.Id = JS.Id WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @ShowItemId", - new { LibraryId = library.Id, ShowItemId = showItemId }) + new { LibraryId = library.Id, ShowItemId = show.ItemId }) .Map(result => result.ToList()); } - public async Task> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId) + public async Task> GetExistingEpisodes(JellyfinLibrary library, JellyfinSeason season) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( - @"SELECT JellyfinEpisode.ItemId, JellyfinEpisode.Etag FROM JellyfinEpisode + @"SELECT JellyfinEpisode.ItemId, JellyfinEpisode.Etag, MI.State FROM JellyfinEpisode INNER JOIN Episode E on JellyfinEpisode.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 JellyfinSeason JS on S2.Id = JS.Id WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @SeasonItemId", - new { LibraryId = library.Id, SeasonItemId = seasonItemId }) + new { LibraryId = library.Id, SeasonItemId = season.ItemId }) .Map(result => result.ToList()); } - public async Task AddShow(JellyfinShow show) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - await dbContext.AddAsync(show); - if (await dbContext.SaveChangesAsync() <= 0) - { - return false; - } - - await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return true; - } - - public async Task> Update(JellyfinShow show) + public async Task>> GetOrAdd( + JellyfinLibrary library, + JellyfinShow item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.JellyfinShows @@ -91,205 +81,26 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository .ThenInclude(mm => mm.Guids) .Include(m => m.TraktListItems) .ThenInclude(tli => tli.TraktList) - .Filter(m => m.ItemId == show.ItemId) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - JellyfinShow existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - show.LibraryPath = existing.LibraryPath; - show.Id = existing.Id; - - existing.Etag = show.Etag; - - // metadata - ShowMetadata metadata = existing.ShowMetadata.Head(); - ShowMetadata incomingMetadata = show.ShowMetadata.Head(); - metadata.MetadataKind = incomingMetadata.MetadataKind; - metadata.ContentRating = incomingMetadata.ContentRating; - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Plot = incomingMetadata.Plot; - metadata.Year = incomingMetadata.Year; - metadata.Tagline = incomingMetadata.Tagline; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - - // genres - foreach (Genre genre in metadata.Genres - .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Remove(genre); - } - - foreach (Genre genre in incomingMetadata.Genres - .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Genres.Add(genre); - } - - // tags - foreach (Tag tag in metadata.Tags - .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) - .Filter(g => g.ExternalCollectionId is null) - .ToList()) - { - metadata.Tags.Remove(tag); - } - - foreach (Tag tag in incomingMetadata.Tags - .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Tags.Add(tag); - } - - // studios - foreach (Studio studio in metadata.Studios - .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Remove(studio); - } - - foreach (Studio studio in incomingMetadata.Studios - .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) - .ToList()) - { - metadata.Studios.Add(studio); - } - - // actors - foreach (Actor actor in metadata.Actors - .Filter( - a => incomingMetadata.Actors.All( - a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) - .ToList()) - { - metadata.Actors.Remove(actor); - } - - foreach (Actor actor in incomingMetadata.Actors - .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) - .ToList()) - { - metadata.Actors.Add(actor); - } - - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } - - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } - - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - - // poster - Artwork incomingPoster = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (incomingPoster != null) - { - Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (poster == null) - { - poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; - metadata.Artwork.Add(poster); - } - - poster.Path = incomingPoster.Path; - poster.DateAdded = incomingPoster.DateAdded; - poster.DateUpdated = incomingPoster.DateUpdated; - } - - // thumbnail - Artwork incomingThumbnail = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (incomingThumbnail != null) - { - Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (thumb == null) - { - thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; - metadata.Artwork.Add(thumb); - } - - thumb.Path = incomingThumbnail.Path; - thumb.DateAdded = incomingThumbnail.DateAdded; - thumb.DateUpdated = incomingThumbnail.DateUpdated; - } - - // fan art - Artwork incomingFanArt = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (incomingFanArt != null) - { - Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (fanArt == null) - { - fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; - metadata.Artwork.Add(fanArt); - } - - fanArt.Path = incomingFanArt.Path; - fanArt.DateAdded = incomingFanArt.DateAdded; - fanArt.DateUpdated = incomingFanArt.DateUpdated; - } - - var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); - foreach (Artwork artworkToRemove in metadata.Artwork - .Filter(a => !paths.Contains(a.Path)) - .ToList()) - { - metadata.Artwork.Remove(artworkToRemove); - } - } - - await dbContext.SaveChangesAsync(); - - return maybeExisting; - } + .SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId); - public async Task AddSeason(JellyfinShow show, JellyfinSeason season) - { - try + foreach (JellyfinShow jellyfinShow in maybeExisting) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - season.ShowId = await dbContext.Connection.ExecuteScalarAsync( - @"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId", - new { show.ItemId }); - - await dbContext.AddAsync(season); - if (await dbContext.SaveChangesAsync() <= 0) + var result = new MediaItemScanResult(jellyfinShow) { IsAdded = false }; + if (jellyfinShow.Etag != item.Etag) { - return false; + await UpdateShow(dbContext, jellyfinShow, item); + result.IsUpdated = true; } - await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return true; - } - catch (Exception) - { - return false; + return result; } + + return await AddShow(dbContext, library, item); } - public async Task> Update(JellyfinSeason season) + public async Task>> GetOrAdd( + JellyfinLibrary library, + JellyfinSeason item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.JellyfinSeasons @@ -298,123 +109,26 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository .ThenInclude(mm => mm.Artwork) .Include(m => m.SeasonMetadata) .ThenInclude(mm => mm.Guids) - .Filter(m => m.ItemId == season.ItemId) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - JellyfinSeason existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - season.LibraryPath = existing.LibraryPath; - season.Id = existing.Id; - - existing.Etag = season.Etag; - existing.SeasonNumber = season.SeasonNumber; - - // metadata - SeasonMetadata metadata = existing.SeasonMetadata.Head(); - SeasonMetadata incomingMetadata = season.SeasonMetadata.Head(); - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Year = incomingMetadata.Year; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - - // poster - Artwork incomingPoster = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (incomingPoster != null) - { - Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); - if (poster == null) - { - poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; - metadata.Artwork.Add(poster); - } - - poster.Path = incomingPoster.Path; - poster.DateAdded = incomingPoster.DateAdded; - poster.DateUpdated = incomingPoster.DateUpdated; - } - - // fan art - Artwork incomingFanArt = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (incomingFanArt != null) - { - Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); - if (fanArt == null) - { - fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; - metadata.Artwork.Add(fanArt); - } - - fanArt.Path = incomingFanArt.Path; - fanArt.DateAdded = incomingFanArt.DateAdded; - fanArt.DateUpdated = incomingFanArt.DateUpdated; - } - - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } - - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } - - var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); - foreach (Artwork artworkToRemove in metadata.Artwork - .Filter(a => !paths.Contains(a.Path)) - .ToList()) - { - metadata.Artwork.Remove(artworkToRemove); - } + .SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId); - await dbContext.SaveChangesAsync(); - await dbContext.Entry(existing.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - } - - return maybeExisting; - } - - public async Task AddEpisode(JellyfinSeason season, JellyfinEpisode episode) - { - try + foreach (JellyfinSeason jellyfinSeason in maybeExisting) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - - episode.SeasonId = await dbContext.Connection.ExecuteScalarAsync( - @"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId", - new { season.ItemId }); - - await dbContext.AddAsync(episode); - if (await dbContext.SaveChangesAsync() <= 0) + var result = new MediaItemScanResult(jellyfinSeason) { IsAdded = false }; + if (jellyfinSeason.Etag != item.Etag) { - return false; + await UpdateSeason(dbContext, jellyfinSeason, item); + result.IsUpdated = true; } - await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); - await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync(); - return true; - } - catch (Exception) - { - return false; + return result; } + + return await AddSeason(dbContext, library, item); } - public async Task> Update(JellyfinEpisode episode) + public async Task>> GetOrAdd( + JellyfinLibrary library, + JellyfinEpisode item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.JellyfinEpisodes @@ -443,188 +157,574 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository .Include(m => m.Season) .Include(m => m.TraktListItems) .ThenInclude(tli => tli.TraktList) - .Filter(m => m.ItemId == episode.ItemId) - .OrderBy(m => m.ItemId) - .SingleOrDefaultAsync(); - - if (maybeExisting.IsSome) - { - JellyfinEpisode existing = maybeExisting.ValueUnsafe(); - - // library path is used for search indexing later - episode.LibraryPath = existing.LibraryPath; - episode.Id = existing.Id; - - existing.Etag = episode.Etag; - - // metadata - // TODO: multiple metadata? - EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); - EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head(); - metadata.Title = incomingMetadata.Title; - metadata.SortTitle = incomingMetadata.SortTitle; - metadata.Plot = incomingMetadata.Plot; - metadata.Year = incomingMetadata.Year; - metadata.DateAdded = incomingMetadata.DateAdded; - metadata.DateUpdated = DateTime.UtcNow; - metadata.ReleaseDate = incomingMetadata.ReleaseDate; - metadata.EpisodeNumber = metadata.EpisodeNumber; - - // thumbnail - Artwork incomingThumbnail = - incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (incomingThumbnail != null) - { - Artwork thumbnail = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); - if (thumbnail == null) - { - thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; - metadata.Artwork.Add(thumbnail); - } - - thumbnail.Path = incomingThumbnail.Path; - thumbnail.DateAdded = incomingThumbnail.DateAdded; - thumbnail.DateUpdated = incomingThumbnail.DateUpdated; - } - - // directors - foreach (Director director in metadata.Directors - .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Remove(director); - } + .SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId); - foreach (Director director in incomingMetadata.Directors - .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) - .ToList()) - { - metadata.Directors.Add(director); - } - - // writers - foreach (Writer writer in metadata.Writers - .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) + foreach (JellyfinEpisode jellyfinEpisode in maybeExisting) + { + var result = new MediaItemScanResult(jellyfinEpisode) { IsAdded = false }; + if (jellyfinEpisode.Etag != item.Etag) { - metadata.Writers.Remove(writer); + await UpdateEpisode(dbContext, jellyfinEpisode, item); + result.IsUpdated = true; } - foreach (Writer writer in incomingMetadata.Writers - .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) - .ToList()) - { - metadata.Writers.Add(writer); - } + return result; + } - // guids - foreach (MetadataGuid guid in metadata.Guids - .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Remove(guid); - } + return await AddEpisode(dbContext, library, item); + } - foreach (MetadataGuid guid in incomingMetadata.Guids - .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) - .ToList()) - { - metadata.Guids.Add(guid); - } + public async Task SetEtag(JellyfinShow show, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE JellyfinShow SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, show.Id }).Map(_ => Unit.Default); + } - var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); - foreach (Artwork artworkToRemove in metadata.Artwork - .Filter(a => !paths.Contains(a.Path)) - .ToList()) - { - metadata.Artwork.Remove(artworkToRemove); - } + public async Task SetEtag(JellyfinSeason season, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE JellyfinSeason SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, season.Id }).Map(_ => Unit.Default); + } - // version - MediaVersion version = existing.MediaVersions.Head(); - MediaVersion incomingVersion = episode.MediaVersions.Head(); - version.Name = incomingVersion.Name; - version.DateAdded = incomingVersion.DateAdded; + public async Task SetEtag(JellyfinEpisode episode, string etag) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.Connection.ExecuteAsync( + "UPDATE JellyfinEpisode SET Etag = @Etag WHERE Id = @Id", + new { Etag = etag, episode.Id }).Map(_ => Unit.Default); + } - // media file - MediaFile file = version.MediaFiles.Head(); - MediaFile incomingFile = incomingVersion.MediaFiles.Head(); - file.Path = incomingFile.Path; - } + public async Task FlagNormal(JellyfinLibrary library, JellyfinEpisode episode) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - await dbContext.SaveChangesAsync(); + episode.State = MediaItemState.Normal; - return maybeExisting; + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT JellyfinEpisode.Id FROM JellyfinEpisode + INNER JOIN MediaItem MI ON MI.Id = JellyfinEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinEpisode.ItemId = @ItemId)", + new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0); } - public async Task> RemoveMissingShows(JellyfinLibrary library, List showIds) + public async Task> FlagFileNotFoundShows(JellyfinLibrary library, List showItemIds) { + if (showItemIds.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 JellyfinShow js ON js.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds", - new { LibraryId = library.Id, ShowIds = showIds }).Map(result => result.ToList()); + @"SELECT M.Id + FROM MediaItem M + INNER JOIN JellyfinShow ON JellyfinShow.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE JellyfinShow.ItemId IN @ShowItemIds", + new { LibraryId = library.Id, ShowItemIds = showItemIds }) + .Map(result => result.ToList()); await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", new { Ids = ids }); return ids; } - public async Task RemoveMissingSeasons(JellyfinLibrary library, List seasonIds) + public async Task> FlagFileNotFoundSeasons(JellyfinLibrary library, List seasonItemIds) { + if (seasonItemIds.Count == 0) + { + return new List(); + } + 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 JellyfinSeason js ON js.Id = m.Id - INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id - WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)", - new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit(); + + List ids = await dbContext.Connection.QueryAsync( + @"SELECT M.Id + FROM MediaItem M + INNER JOIN JellyfinSeason ON JellyfinSeason.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE JellyfinSeason.ItemId IN @SeasonItemIds", + new { LibraryId = library.Id, SeasonItemIds = seasonItemIds }) + .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> RemoveMissingEpisodes(JellyfinLibrary library, List episodeIds) + public async Task> FlagFileNotFoundEpisodes(JellyfinLibrary library, List episodeItemIds) { + if (episodeItemIds.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 JellyfinEpisode je ON je.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - WHERE lp.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds", - new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList()); + @"SELECT M.Id + FROM MediaItem M + INNER JOIN JellyfinEpisode ON JellyfinEpisode.Id = M.Id + INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId + WHERE JellyfinEpisode.ItemId IN @EpisodeItemIds", + new { LibraryId = library.Id, EpisodeItemIds = episodeItemIds }) + .Map(result => result.ToList()); await dbContext.Connection.ExecuteAsync( - "DELETE FROM MediaItem WHERE Id IN @Ids", + @"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids", new { Ids = ids }); return ids; } - public async Task DeleteEmptySeasons(JellyfinLibrary library) + public async Task> FlagUnavailable(JellyfinLibrary library, JellyfinEpisode episode) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - List seasons = await dbContext.JellyfinSeasons - .Filter(s => s.LibraryPath.LibraryId == library.Id) - .Filter(s => s.Episodes.Count == 0) - .ToListAsync(); - dbContext.Seasons.RemoveRange(seasons); + + episode.State = MediaItemState.Unavailable; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"SELECT JellyfinEpisode.Id FROM JellyfinEpisode + INNER JOIN MediaItem MI ON MI.Id = JellyfinEpisode.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinEpisode.ItemId = @ItemId", + new { LibraryId = library.Id, episode.ItemId }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } + + private async Task UpdateShow(TvContext dbContext, JellyfinShow existing, JellyfinShow incoming) + { + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + // metadata + ShowMetadata metadata = existing.ShowMetadata.Head(); + ShowMetadata incomingMetadata = incoming.ShowMetadata.Head(); + metadata.MetadataKind = incomingMetadata.MetadataKind; + metadata.ContentRating = incomingMetadata.ContentRating; + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.Tagline = incomingMetadata.Tagline; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + + // genres + foreach (Genre genre in metadata.Genres + .Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Remove(genre); + } + + foreach (Genre genre in incomingMetadata.Genres + .Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Genres.Add(genre); + } + + // tags + foreach (Tag tag in metadata.Tags + .Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) + .Filter(g => g.ExternalCollectionId is null) + .ToList()) + { + metadata.Tags.Remove(tag); + } + + foreach (Tag tag in incomingMetadata.Tags + .Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Tags.Add(tag); + } + + // studios + foreach (Studio studio in metadata.Studios + .Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Remove(studio); + } + + foreach (Studio studio in incomingMetadata.Studios + .Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) + .ToList()) + { + metadata.Studios.Add(studio); + } + + // actors + foreach (Actor actor in metadata.Actors + .Filter( + a => incomingMetadata.Actors.All( + a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) + .ToList()) + { + metadata.Actors.Remove(actor); + } + + foreach (Actor actor in incomingMetadata.Actors + .Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) + .ToList()) + { + metadata.Actors.Add(actor); + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + + var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); + foreach (Artwork artworkToRemove in metadata.Artwork + .Filter(a => !paths.Contains(a.Path)) + .ToList()) + { + metadata.Artwork.Remove(artworkToRemove); + } + await dbContext.SaveChangesAsync(); - return Unit.Default; } - public async Task> DeleteEmptyShows(JellyfinLibrary library) + private async Task UpdateSeason(TvContext dbContext, JellyfinSeason existing, JellyfinSeason incoming) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - List shows = await dbContext.JellyfinShows - .Filter(s => s.LibraryPath.LibraryId == library.Id) - .Filter(s => s.Seasons.Count == 0) - .ToListAsync(); - var ids = shows.Map(s => s.Id).ToList(); - dbContext.Shows.RemoveRange(shows); + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + existing.SeasonNumber = incoming.SeasonNumber; + + // metadata + SeasonMetadata metadata = existing.SeasonMetadata.Head(); + SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Year = incomingMetadata.Year; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + + // poster + Artwork incomingPoster = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (incomingPoster != null) + { + Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); + if (poster == null) + { + poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; + metadata.Artwork.Add(poster); + } + + poster.Path = incomingPoster.Path; + poster.DateAdded = incomingPoster.DateAdded; + poster.DateUpdated = incomingPoster.DateUpdated; + } + + // thumbnail + Artwork incomingThumbnail = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (incomingThumbnail != null) + { + Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (thumb == null) + { + thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; + metadata.Artwork.Add(thumb); + } + + thumb.Path = incomingThumbnail.Path; + thumb.DateAdded = incomingThumbnail.DateAdded; + thumb.DateUpdated = incomingThumbnail.DateUpdated; + } + + // fan art + Artwork incomingFanArt = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (incomingFanArt != null) + { + Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); + if (fanArt == null) + { + fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; + metadata.Artwork.Add(fanArt); + } + + fanArt.Path = incomingFanArt.Path; + fanArt.DateAdded = incomingFanArt.DateAdded; + fanArt.DateUpdated = incomingFanArt.DateUpdated; + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); + foreach (Artwork artworkToRemove in metadata.Artwork + .Filter(a => !paths.Contains(a.Path)) + .ToList()) + { + metadata.Artwork.Remove(artworkToRemove); + } + await dbContext.SaveChangesAsync(); - return ids; + } + + public async Task UpdateEpisode(TvContext dbContext, JellyfinEpisode existing, JellyfinEpisode incoming) + { + // library path is used for search indexing later + incoming.LibraryPath = existing.LibraryPath; + incoming.Id = existing.Id; + + // metadata + // TODO: multiple metadata? + EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); + EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head(); + metadata.Title = incomingMetadata.Title; + metadata.SortTitle = incomingMetadata.SortTitle; + metadata.Plot = incomingMetadata.Plot; + metadata.Year = incomingMetadata.Year; + metadata.DateAdded = incomingMetadata.DateAdded; + metadata.DateUpdated = DateTime.UtcNow; + metadata.ReleaseDate = incomingMetadata.ReleaseDate; + metadata.EpisodeNumber = incomingMetadata.EpisodeNumber; + + // thumbnail + Artwork incomingThumbnail = + incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (incomingThumbnail != null) + { + Artwork thumbnail = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); + if (thumbnail == null) + { + thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; + metadata.Artwork.Add(thumbnail); + } + + thumbnail.Path = incomingThumbnail.Path; + thumbnail.DateAdded = incomingThumbnail.DateAdded; + thumbnail.DateUpdated = incomingThumbnail.DateUpdated; + } + + // directors + foreach (Director director in metadata.Directors + .Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Remove(director); + } + + foreach (Director director in incomingMetadata.Directors + .Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)) + .ToList()) + { + metadata.Directors.Add(director); + } + + // writers + foreach (Writer writer in metadata.Writers + .Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Remove(writer); + } + + foreach (Writer writer in incomingMetadata.Writers + .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)) + .ToList()) + { + metadata.Writers.Add(writer); + } + + // guids + foreach (MetadataGuid guid in metadata.Guids + .Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Remove(guid); + } + + foreach (MetadataGuid guid in incomingMetadata.Guids + .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)) + .ToList()) + { + metadata.Guids.Add(guid); + } + + var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList(); + foreach (Artwork artworkToRemove in metadata.Artwork + .Filter(a => !paths.Contains(a.Path)) + .ToList()) + { + metadata.Artwork.Remove(artworkToRemove); + } + + // version + MediaVersion version = existing.MediaVersions.Head(); + MediaVersion incomingVersion = incoming.MediaVersions.Head(); + version.Name = incomingVersion.Name; + version.DateAdded = incomingVersion.DateAdded; + + // media file + MediaFile file = version.MediaFiles.Head(); + MediaFile incomingFile = incomingVersion.MediaFiles.Head(); + file.Path = incomingFile.Path; + + await dbContext.SaveChangesAsync(); + } + + private async Task>> AddShow( + TvContext dbContext, + JellyfinLibrary library, + JellyfinShow show) + { + try + { + // blank out etag for initial save in case other updates fail + show.Etag = string.Empty; + + show.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(show); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(show) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } + + private async Task>> AddSeason( + TvContext dbContext, + JellyfinLibrary library, + JellyfinSeason season) + { + try + { + // blank out etag for initial save in case other updates fail + season.Etag = string.Empty; + + season.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(season); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(season) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } + } + + private async Task>> AddEpisode( + TvContext dbContext, + JellyfinLibrary library, + JellyfinEpisode episode) + { + try + { + // blank out etag for initial save in case other updates fail + episode.Etag = string.Empty; + + episode.LibraryPathId = library.Paths.Head().Id; + + await dbContext.AddAsync(episode); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); + await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(episode) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.ToString()); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index d1a964a7..c3c3758f 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -901,69 +901,109 @@ public class MediaSourceRepository : IMediaSourceRepository List movieIds = await dbContext.Connection.QueryAsync( @"SELECT m.Id FROM MediaItem m INNER JOIN EmbyMovie pm ON pm.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids", + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids", new { ids = libraryIds }).Map(result => result.ToList()); + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM EmbyMovie WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyMovie pm ON pm.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM Movie WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyMovie pm ON pm.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + await dbContext.Connection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN (SELECT m.Id FROM MediaItem m INNER JOIN EmbyMovie pm ON pm.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids)", + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", new { ids = libraryIds }); List episodeIds = await dbContext.Connection.QueryAsync( @"SELECT m.Id FROM MediaItem m INNER JOIN EmbyEpisode pe ON pe.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids", + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids", new { ids = libraryIds }).Map(result => result.ToList()); + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM EmbyEpisode WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyEpisode pe ON pe.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM Episode WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyEpisode pe ON pe.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + await dbContext.Connection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN (SELECT m.Id FROM MediaItem m INNER JOIN EmbyEpisode pe ON pe.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids)", + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", new { ids = libraryIds }); List seasonIds = await dbContext.Connection.QueryAsync( @"SELECT m.Id FROM MediaItem m INNER JOIN EmbySeason es ON es.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids", + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids", new { ids = libraryIds }).Map(result => result.ToList()); + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM EmbySeason WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbySeason es ON es.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM Season WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbySeason es ON es.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + await dbContext.Connection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN (SELECT m.Id FROM MediaItem m - INNER JOIN EmbySeason ps ON ps.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids)", + INNER JOIN EmbySeason es ON es.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", new { ids = libraryIds }); List showIds = await dbContext.Connection.QueryAsync( @"SELECT m.Id FROM MediaItem m INNER JOIN EmbyShow ps ON ps.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids", + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids", new { ids = libraryIds }).Map(result => result.ToList()); + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM EmbyShow WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + + await dbContext.Connection.ExecuteAsync( + @"DELETE FROM Show WHERE Id IN + (SELECT m.Id FROM MediaItem m + INNER JOIN EmbyShow ps ON ps.Id = m.Id + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", + new { ids = libraryIds }); + await dbContext.Connection.ExecuteAsync( @"DELETE FROM MediaItem WHERE Id IN (SELECT m.Id FROM MediaItem m INNER JOIN EmbyShow ps ON ps.Id = m.Id - INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId - INNER JOIN Library l ON l.Id = lp.LibraryId - WHERE l.Id IN @ids)", + INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", new { ids = libraryIds }); return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs index c5115fcb..94c4cc2c 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs @@ -1,7 +1,10 @@ using Dapper; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; +using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data.Repositories; @@ -13,7 +16,45 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository public PlexTelevisionRepository(IDbContextFactory dbContextFactory) => _dbContextFactory = dbContextFactory; - public async Task> GetExistingPlexShows(PlexLibrary library) + 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; + + Option maybeId = await dbContext.Connection.ExecuteScalarAsync( + @"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 }); + + foreach (int id in maybeId) + { + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 2 WHERE Id = @Id", + new { Id = id }).Map(count => count > 0 ? Some(id) : None); + } + + return None; + } + + public async Task> GetExistingShows(PlexLibrary library) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( @@ -24,7 +65,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository .Map(result => result.ToList()); } - public async Task> GetExistingPlexSeasons(PlexLibrary library, PlexShow show) + public async Task> GetExistingSeasons(PlexLibrary library, PlexShow show) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( @@ -38,7 +79,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository .Map(result => result.ToList()); } - public async Task> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) + public async Task> GetExistingEpisodes(PlexLibrary library, PlexSeason season) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QueryAsync( @@ -53,42 +94,128 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository .Map(result => result.ToList()); } - public async Task FlagNormal(PlexLibrary library, PlexEpisode episode) + public async Task>> GetOrAdd(PlexLibrary library, PlexShow item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + Option maybeExisting = await dbContext.PlexShows + .AsNoTracking() + .Include(i => i.ShowMetadata) + .ThenInclude(sm => sm.Genres) + .Include(i => i.ShowMetadata) + .ThenInclude(sm => sm.Tags) + .Include(i => i.ShowMetadata) + .ThenInclude(sm => sm.Studios) + .Include(i => i.ShowMetadata) + .ThenInclude(sm => sm.Actors) + .ThenInclude(a => a.Artwork) + .Include(i => i.ShowMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(i => i.ShowMetadata) + .ThenInclude(sm => sm.Guids) + .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(i => i.TraktListItems) + .ThenInclude(tli => tli.TraktList) + .SelectOneAsync(i => i.Key, i => i.Key == item.Key); + + foreach (PlexShow plexShow in maybeExisting) + { + return new MediaItemScanResult(plexShow) { IsAdded = false }; + } - 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); + return await AddShow(dbContext, library, item); } - public async Task> FlagUnavailable(PlexLibrary library, PlexEpisode episode) + public async Task>> GetOrAdd(PlexLibrary library, PlexSeason item) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + Option maybeExisting = await dbContext.PlexSeasons + .AsNoTracking() + .Include(i => i.SeasonMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(i => i.SeasonMetadata) + .ThenInclude(sm => sm.Guids) + .Include(i => i.SeasonMetadata) + .ThenInclude(sm => sm.Tags) + .Include(s => s.LibraryPath) + .ThenInclude(l => l.Library) + .Include(s => s.TraktListItems) + .ThenInclude(tli => tli.TraktList) + .SelectOneAsync(i => i.Key, i => i.Key == item.Key); + + foreach (PlexSeason plexSeason in maybeExisting) + { + return new MediaItemScanResult(plexSeason) { IsAdded = false }; + } - episode.State = MediaItemState.Unavailable; - - Option maybeId = await dbContext.Connection.ExecuteScalarAsync( - @"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 }); + return await AddSeason(dbContext, library, item); + } - foreach (int id in maybeId) + public async Task>> GetOrAdd( + PlexLibrary library, + PlexEpisode item) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + Option maybeExisting = await dbContext.PlexEpisodes + .AsNoTracking() + .Include(i => i.EpisodeMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.EpisodeMetadata) + .ThenInclude(mm => mm.Genres) + .Include(i => i.EpisodeMetadata) + .ThenInclude(mm => mm.Tags) + .Include(i => i.EpisodeMetadata) + .ThenInclude(mm => mm.Studios) + .Include(i => i.EpisodeMetadata) + .ThenInclude(mm => mm.Directors) + .Include(i => i.EpisodeMetadata) + .ThenInclude(mm => mm.Writers) + .Include(i => i.MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(e => e.EpisodeMetadata) + .ThenInclude(em => em.Actors) + .ThenInclude(a => a.Artwork) + .Include(e => e.EpisodeMetadata) + .ThenInclude(em => em.Guids) + .Include(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(e => e.Season) + .Include(e => e.TraktListItems) + .ThenInclude(tli => tli.TraktList) + .SelectOneAsync(i => i.Key, i => i.Key == item.Key); + + foreach (PlexEpisode plexEpisode in maybeExisting) { - return await dbContext.Connection.ExecuteAsync( - @"UPDATE MediaItem SET State = 2 WHERE Id = @Id", - new { Id = id }).Map(count => count > 0 ? Some(id) : None); + return new MediaItemScanResult(plexEpisode) { IsAdded = false }; } - return None; + return await AddEpisode(dbContext, library, item); + } + + public async Task SetEtag(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 SetEtag(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 SetEtag(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> FlagFileNotFoundShows(PlexLibrary library, List plexShowKeys) @@ -166,27 +293,93 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository return ids; } - public async Task SetPlexEtag(PlexShow show, string etag) + private static async Task>> AddShow( + TvContext dbContext, + PlexLibrary library, + PlexShow item) { - 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); + try + { + // blank out etag for initial save in case stats/metadata/etc updates fail + item.Etag = string.Empty; + + item.LibraryPathId = library.Paths.Head().Id; + + await dbContext.PlexShows.AddAsync(item); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(item) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } } - public async Task SetPlexEtag(PlexSeason season, string etag) + private static async Task>> AddSeason( + TvContext dbContext, + PlexLibrary library, + PlexSeason item) { - 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); + try + { + // blank out etag for initial save in case stats/metadata/etc updates fail + item.Etag = string.Empty; + + item.LibraryPathId = library.Paths.Head().Id; + + await dbContext.PlexSeasons.AddAsync(item); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + return new MediaItemScanResult(item) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } } - public async Task SetPlexEtag(PlexEpisode episode, string etag) + private static async Task>> AddEpisode( + TvContext dbContext, + PlexLibrary library, + PlexEpisode item) { - 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); + try + { + if (dbContext.MediaFiles.Any(mf => mf.Path == item.MediaVersions.Head().MediaFiles.Head().Path)) + { + return BaseError.New("Multi-episode files are not yet supported"); + } + + // blank out etag for initial save in case stats/metadata/etc updates fail + item.Etag = string.Empty; + + item.LibraryPathId = library.Paths.Head().Id; + foreach (EpisodeMetadata metadata in item.EpisodeMetadata) + { + metadata.Genres ??= new List(); + metadata.Tags ??= new List(); + metadata.Studios ??= new List(); + metadata.Actors ??= new List(); + metadata.Directors ??= new List(); + metadata.Writers ??= new List(); + } + + await dbContext.PlexEpisodes.AddAsync(item); + await dbContext.SaveChangesAsync(); + + await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); + await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); + await dbContext.Entry(item).Reference(e => e.Season).LoadAsync(); + return new MediaItemScanResult(item) { IsAdded = true }; + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } } } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index 97565919..45eb39aa 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -438,104 +438,6 @@ public class TelevisionRepository : ITelevisionRepository return ids; } - public async Task>> GetOrAddPlexShow( - PlexLibrary library, - PlexShow item) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - Option maybeExisting = await dbContext.PlexShows - .AsNoTracking() - .Include(i => i.ShowMetadata) - .ThenInclude(sm => sm.Genres) - .Include(i => i.ShowMetadata) - .ThenInclude(sm => sm.Tags) - .Include(i => i.ShowMetadata) - .ThenInclude(sm => sm.Studios) - .Include(i => i.ShowMetadata) - .ThenInclude(sm => sm.Actors) - .ThenInclude(a => a.Artwork) - .Include(i => i.ShowMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(i => i.ShowMetadata) - .ThenInclude(sm => sm.Guids) - .Include(i => i.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(i => i.TraktListItems) - .ThenInclude(tli => tli.TraktList) - .OrderBy(i => i.Key) - .SingleOrDefaultAsync(i => i.Key == item.Key); - - return await maybeExisting.Match( - plexShow => Right>( - new MediaItemScanResult(plexShow) { IsAdded = false }).AsTask(), - async () => await AddPlexShow(dbContext, library, item)); - } - - public async Task> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - Option maybeExisting = await dbContext.PlexSeasons - .AsNoTracking() - .Include(i => i.SeasonMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(i => i.SeasonMetadata) - .ThenInclude(sm => sm.Guids) - .Include(i => i.SeasonMetadata) - .ThenInclude(sm => sm.Tags) - .Include(s => s.LibraryPath) - .ThenInclude(l => l.Library) - .Include(s => s.TraktListItems) - .ThenInclude(tli => tli.TraktList) - .OrderBy(i => i.Key) - .SingleOrDefaultAsync(i => i.Key == item.Key); - - return await maybeExisting.Match( - plexSeason => Right(plexSeason).AsTask(), - async () => await AddPlexSeason(dbContext, library, item)); - } - - public async Task>> GetOrAddPlexEpisode( - PlexLibrary library, - PlexEpisode item) - { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); - Option maybeExisting = await dbContext.PlexEpisodes - .AsNoTracking() - .Include(i => i.EpisodeMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(i => i.EpisodeMetadata) - .ThenInclude(mm => mm.Genres) - .Include(i => i.EpisodeMetadata) - .ThenInclude(mm => mm.Tags) - .Include(i => i.EpisodeMetadata) - .ThenInclude(mm => mm.Studios) - .Include(i => i.EpisodeMetadata) - .ThenInclude(mm => mm.Directors) - .Include(i => i.EpisodeMetadata) - .ThenInclude(mm => mm.Writers) - .Include(i => i.MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(i => i.MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(e => e.EpisodeMetadata) - .ThenInclude(em => em.Actors) - .ThenInclude(a => a.Artwork) - .Include(e => e.EpisodeMetadata) - .ThenInclude(em => em.Guids) - .Include(i => i.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(e => e.Season) - .Include(e => e.TraktListItems) - .ThenInclude(tli => tli.TraktList) - .OrderBy(i => i.Key) - .SingleOrDefaultAsync(i => i.Key == item.Key); - - return await maybeExisting.Match( - plexEpisode => Right>( - new MediaItemScanResult(plexEpisode) { IsAdded = false }).AsTask(), - async () => await AddPlexEpisode(dbContext, library, item)); - } - public async Task RemoveMetadata(Episode episode, EpisodeMetadata metadata) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -802,94 +704,4 @@ public class TelevisionRepository : ITelevisionRepository return BaseError.New(ex.Message); } } - - private static async Task>> AddPlexShow( - TvContext dbContext, - PlexLibrary library, - PlexShow item) - { - try - { - // blank out etag for initial save in case stats/metadata/etc updates fail - item.Etag = string.Empty; - - item.LibraryPathId = library.Paths.Head().Id; - - await dbContext.PlexShows.AddAsync(item); - await dbContext.SaveChangesAsync(); - - await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); - await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return new MediaItemScanResult(item) { IsAdded = true }; - } - catch (Exception ex) - { - return BaseError.New(ex.Message); - } - } - - private static async Task> AddPlexSeason( - TvContext dbContext, - PlexLibrary library, - PlexSeason item) - { - try - { - // blank out etag for initial save in case stats/metadata/etc updates fail - item.Etag = string.Empty; - - item.LibraryPathId = library.Paths.Head().Id; - - await dbContext.PlexSeasons.AddAsync(item); - await dbContext.SaveChangesAsync(); - - await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); - await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - return item; - } - catch (Exception ex) - { - return BaseError.New(ex.Message); - } - } - - private static async Task>> AddPlexEpisode( - TvContext dbContext, - PlexLibrary library, - PlexEpisode item) - { - try - { - if (dbContext.MediaFiles.Any(mf => mf.Path == item.MediaVersions.Head().MediaFiles.Head().Path)) - { - return BaseError.New("Multi-episode files are not yet supported"); - } - - // blank out etag for initial save in case stats/metadata/etc updates fail - item.Etag = string.Empty; - - item.LibraryPathId = library.Paths.Head().Id; - foreach (EpisodeMetadata metadata in item.EpisodeMetadata) - { - metadata.Genres ??= new List(); - metadata.Tags ??= new List(); - metadata.Studios ??= new List(); - metadata.Actors ??= new List(); - metadata.Directors ??= new List(); - metadata.Writers ??= new List(); - } - - await dbContext.PlexEpisodes.AddAsync(item); - await dbContext.SaveChangesAsync(); - - await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); - await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); - await dbContext.Entry(item).Reference(e => e.Season).LoadAsync(); - return new MediaItemScanResult(item) { IsAdded = true }; - } - catch (Exception ex) - { - return BaseError.New(ex.Message); - } - } } diff --git a/ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.Designer.cs new file mode 100644 index 00000000..7fee10c3 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.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("20220428020137_Remove_InvalidPlexSeasons")] + partial class Remove_InvalidPlexSeasons + { + 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/20220428020137_Remove_InvalidPlexSeasons.cs b/ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.cs new file mode 100644 index 00000000..779bdd30 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class Remove_InvalidPlexSeasons : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@"DELETE FROM PlexSeason WHERE Key LIKE '%allLeaves%'"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index ad4ebe6c..7ef51006 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -120,7 +120,7 @@ public class PlexServerApiClient : IPlexServerApiClient { IPlexServerApi service = XmlServiceFor(connection.Uri); return await service.GetShowChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken) - .Map(r => r.Metadata) + .Map(r => r.Metadata.Filter(m => !m.Key.Contains("allLeaves"))) .Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)).ToList()); } catch (Exception ex)