using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories.Caching; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Trakt; using ErsatzTV.Core.Trakt; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.MediaCollections; public abstract class TraktCommandBase { private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly ILogger _logger; private readonly ISearchIndex _searchIndex; private readonly ICachingSearchRepository _searchRepository; protected TraktCommandBase( ITraktApiClient traktApiClient, ICachingSearchRepository searchRepository, ISearchIndex searchIndex, IFallbackMetadataProvider fallbackMetadataProvider, ILogger logger) { _searchRepository = searchRepository; _searchIndex = searchIndex; _fallbackMetadataProvider = fallbackMetadataProvider; _logger = logger; TraktApiClient = traktApiClient; } protected ITraktApiClient TraktApiClient { get; } protected static Task> TraktListMustExist(TvContext dbContext, int traktListId) => dbContext.TraktLists .Include(l => l.Items) .ThenInclude(i => i.Guids) .SelectOneAsync(c => c.Id, c => c.Id == traktListId) .Map(o => o.ToValidation($"TraktList {traktListId} does not exist.")); protected static async Task> SaveList(TvContext dbContext, TraktList list) { Option maybeExisting = await dbContext.TraktLists .Include(l => l.Items) .ThenInclude(i => i.Guids) .SelectOneAsync(tl => tl.Id, tl => tl.User == list.User && tl.List == list.List); return await maybeExisting.Match( async existing => { existing.Name = list.Name; existing.Description = list.Description; existing.ItemCount = list.ItemCount; await dbContext.SaveChangesAsync(); return existing; }, async () => { await dbContext.TraktLists.AddAsync(list); await dbContext.SaveChangesAsync(); return list; }); } protected async Task> SaveListItems(TvContext dbContext, TraktList list) { Either> maybeItems = await TraktApiClient.GetUserListItems(list.User, list.List); return await maybeItems.Match>>( async items => { var toAdd = items.Filter(i => list.Items.All(i2 => i2.TraktId != i.TraktId)).ToList(); var toRemove = list.Items.Filter(i => items.All(i2 => i2.TraktId != i.TraktId)).ToList(); var toUpdate = list.Items.Filter(i => !toRemove.Contains(i)).ToList(); list.Items.RemoveAll(toRemove.Contains); list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a))); foreach (TraktListItem existing in toUpdate) { Option maybeIncoming = items .Filter(i => i.TraktId == existing.TraktId) .Map(i => ProjectItem(list, i)) .HeadOrNone(); foreach (TraktListItem incoming in maybeIncoming) { existing.Kind = incoming.Kind; existing.Rank = incoming.Rank; existing.Title = incoming.Title; existing.Year = incoming.Year; existing.Season = incoming.Season; existing.Episode = incoming.Episode; existing.Guids.Clear(); existing.Guids.AddRange(incoming.Guids); existing.MediaItemId = null; existing.MediaItem = null; } } await dbContext.SaveChangesAsync(); return list; }, error => Task.FromResult(Left(error))); } protected async Task> MatchListItems(TvContext dbContext, TraktList list) { try { var ids = new System.Collections.Generic.HashSet(); foreach (TraktListItem item in list.Items .OrderBy(i => i.Title).ThenBy(i => i.Year).ThenBy(i => i.Season).ThenBy(i => i.Episode)) { switch (item.Kind) { case TraktListItemKind.Movie: Option maybeMovieId = await IdentifyMovie(dbContext, item); foreach (int movieId in maybeMovieId) { ids.Add(movieId); item.MediaItemId = movieId; } break; case TraktListItemKind.Show: Option maybeShowId = await IdentifyShow(dbContext, item); foreach (int showId in maybeShowId) { ids.Add(showId); item.MediaItemId = showId; } break; case TraktListItemKind.Season: Option maybeSeasonId = await IdentifySeason(dbContext, item); foreach (int seasonId in maybeSeasonId) { ids.Add(seasonId); item.MediaItemId = seasonId; } break; default: Option maybeEpisodeId = await IdentifyEpisode(dbContext, item); foreach (int episodeId in maybeEpisodeId) { ids.Add(episodeId); item.MediaItemId = episodeId; } break; } } if (await dbContext.SaveChangesAsync() > 0) { await _searchIndex.RebuildItems(_searchRepository, _fallbackMetadataProvider, ids.ToList()); } _searchIndex.Commit(); return list; } catch (Exception ex) { _logger.LogError(ex, "Error matching trakt list items"); return BaseError.New(ex.Message); } } private static TraktListItem ProjectItem(TraktList list, TraktListItemWithGuids item) { var result = new TraktListItem { TraktList = list, Kind = item.Kind, TraktId = item.TraktId, Rank = item.Rank, Title = item.Title, Year = item.Year, Season = item.Season, Episode = item.Episode }; result.Guids = item.Guids.Map(g => new TraktListItemGuid { Guid = g, TraktListItem = result }).ToList(); return result; } private static async Task> IdentifyMovie(TvContext dbContext, TraktListItem item) { var guids = item.Guids.Map(g => g.Guid).ToList(); Option maybeMovieByGuid = await dbContext.MovieMetadata .AsNoTracking() .Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid))) .FirstOrDefaultAsync() .Map(Optional) .MapT(mm => mm.MovieId); foreach (int movieId in maybeMovieByGuid) { // _logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle); return movieId; } Option maybeMovieByTitleYear = await dbContext.MovieMetadata .AsNoTracking() .Filter(mm => mm.Title == item.Title && mm.Year == item.Year) .FirstOrDefaultAsync() .Map(Optional) .MapT(mm => mm.MovieId); foreach (int movieId in maybeMovieByTitleYear) { // _logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle); return movieId; } // _logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle); return None; } private static async Task> IdentifyShow(TvContext dbContext, TraktListItem item) { var guids = item.Guids.Map(g => g.Guid).ToList(); Option maybeShowByGuid = await dbContext.ShowMetadata .AsNoTracking() .Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid))) .FirstOrDefaultAsync() .Map(Optional) .MapT(sm => sm.ShowId); foreach (int showId in maybeShowByGuid) { // _logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle); return showId; } Option maybeShowByTitleYear = await dbContext.ShowMetadata .AsNoTracking() .Filter(sm => sm.Title == item.Title && sm.Year == item.Year) .FirstOrDefaultAsync() .Map(Optional) .MapT(sm => sm.ShowId); foreach (int showId in maybeShowByTitleYear) { // _logger.LogDebug("Located trakt show {Title} by title/year", item.Title); return showId; } // _logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle); return None; } private static async Task> IdentifySeason(TvContext dbContext, TraktListItem item) { var guids = item.Guids.Map(g => g.Guid).ToList(); Option maybeSeasonByGuid = await dbContext.SeasonMetadata .AsNoTracking() .Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid))) .FirstOrDefaultAsync() .Map(Optional) .MapT(sm => sm.SeasonId); foreach (int seasonId in maybeSeasonByGuid) { // _logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle); return seasonId; } Option maybeSeasonByTitleYear = await dbContext.SeasonMetadata .AsNoTracking() .Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year)) .Filter(sm => sm.Season.SeasonNumber == item.Season) .FirstOrDefaultAsync() .Map(Optional) .MapT(sm => sm.SeasonId); foreach (int seasonId in maybeSeasonByTitleYear) { // _logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle); return seasonId; } // _logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle); return None; } private static async Task> IdentifyEpisode(TvContext dbContext, TraktListItem item) { var guids = item.Guids.Map(g => g.Guid).ToList(); Option maybeEpisodeByGuid = await dbContext.EpisodeMetadata .AsNoTracking() .Filter(em => em.Guids.Any(g => guids.Contains(g.Guid))) .FirstOrDefaultAsync() .Map(Optional) .MapT(sm => sm.EpisodeId); foreach (int episodeId in maybeEpisodeByGuid) { // _logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle); return episodeId; } Option maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata .AsNoTracking() .Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year)) .Filter(em => em.Episode.Season.SeasonNumber == item.Season) .Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode)) .FirstOrDefaultAsync() .Map(Optional) .MapT(sm => sm.EpisodeId); foreach (int episodeId in maybeEpisodeByTitleYear) { // _logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle); return episodeId; } // _logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle); return None; } }