using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Books.Commands; using NzbDrone.Core.Books.Events; using NzbDrone.Core.Exceptions; using NzbDrone.Core.History; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.Books { public interface IRefreshBookService { bool RefreshBookInfo(Book book, List remoteBooks, Author remoteData, bool forceUpdateFileTags); bool RefreshBookInfo(List books, List remoteBooks, Author remoteData, bool forceBookRefresh, bool forceUpdateFileTags, DateTime? lastUpdate); } public class RefreshBookService : RefreshEntityServiceBase, IRefreshBookService, IExecute, IExecute { private readonly IBookService _bookService; private readonly IAuthorService _authorService; private readonly IRootFolderService _rootFolderService; private readonly IAddAuthorService _addAuthorService; private readonly IEditionService _editionService; private readonly IProvideAuthorInfo _authorInfo; private readonly IProvideBookInfo _bookInfo; private readonly IRefreshEditionService _refreshEditionService; private readonly IMediaFileService _mediaFileService; private readonly IHistoryService _historyService; private readonly IEventAggregator _eventAggregator; private readonly ICheckIfBookShouldBeRefreshed _checkIfBookShouldBeRefreshed; private readonly IMapCoversToLocal _mediaCoverService; private readonly Logger _logger; public RefreshBookService(IBookService bookService, IAuthorService authorService, IRootFolderService rootFolderService, IAddAuthorService addAuthorService, IEditionService editionService, IAuthorMetadataService authorMetadataService, IProvideAuthorInfo authorInfo, IProvideBookInfo bookInfo, IRefreshEditionService refreshEditionService, IMediaFileService mediaFileService, IHistoryService historyService, IEventAggregator eventAggregator, ICheckIfBookShouldBeRefreshed checkIfBookShouldBeRefreshed, IMapCoversToLocal mediaCoverService, Logger logger) : base(logger, authorMetadataService) { _bookService = bookService; _authorService = authorService; _rootFolderService = rootFolderService; _addAuthorService = addAuthorService; _editionService = editionService; _authorInfo = authorInfo; _bookInfo = bookInfo; _refreshEditionService = refreshEditionService; _mediaFileService = mediaFileService; _historyService = historyService; _eventAggregator = eventAggregator; _checkIfBookShouldBeRefreshed = checkIfBookShouldBeRefreshed; _mediaCoverService = mediaCoverService; _logger = logger; } private Author GetSkyhookData(Book book) { try { var tuple = _bookInfo.GetBookInfo(book.ForeignBookId); var author = _authorInfo.GetAuthorInfo(tuple.Item1); var newbook = tuple.Item2; newbook.Author = author; newbook.AuthorMetadata = author.Metadata.Value; newbook.AuthorMetadataId = book.AuthorMetadataId; newbook.AuthorMetadata.Value.Id = book.AuthorMetadataId; author.Books = new List { newbook }; return author; } catch (BookNotFoundException) { _logger.Error($"Could not find book with id {book.ForeignBookId}"); } return null; } protected override RemoteData GetRemoteData(Book local, List remote, Author data) { var result = new RemoteData(); var book = remote.SingleOrDefault(x => x.ForeignBookId == local.ForeignBookId); if (book == null && ShouldDelete(local)) { return result; } if (book == null) { data = GetSkyhookData(local); book = data.Books.Value.SingleOrDefault(x => x.ForeignBookId == local.ForeignBookId); } result.Entity = book; if (result.Entity != null) { result.Entity.Id = local.Id; } return result; } protected override void EnsureNewParent(Book local, Book remote) { // Make sure the appropriate author exists (it could be that an book changes parent) // The authorMetadata entry will be in the db but make sure a corresponding author is too // so that the book doesn't just disappear. // TODO filter by metadata id before hitting database _logger.Trace($"Ensuring parent author exists [{remote.AuthorMetadata.Value.ForeignAuthorId}]"); var newAuthor = _authorService.FindById(remote.AuthorMetadata.Value.ForeignAuthorId); if (newAuthor == null) { var oldAuthor = local.Author.Value; var addAuthor = new Author { Metadata = remote.AuthorMetadata.Value, MetadataProfileId = oldAuthor.MetadataProfileId, QualityProfileId = oldAuthor.QualityProfileId, RootFolderPath = _rootFolderService.GetBestRootFolderPath(oldAuthor.Path), Monitored = oldAuthor.Monitored, Tags = oldAuthor.Tags }; _logger.Debug($"Adding missing parent author {addAuthor}"); _addAuthorService.AddAuthor(addAuthor); } } protected override bool ShouldDelete(Book local) { // not manually added and has no files return local.AddOptions.AddType != BookAddType.Manual && !_mediaFileService.GetFilesByBook(local.Id).Any(); } protected override void LogProgress(Book local) { _logger.ProgressInfo("Updating Info for {0}", local.Title); } protected override bool IsMerge(Book local, Book remote) { return local.ForeignBookId != remote.ForeignBookId; } protected override UpdateResult UpdateEntity(Book local, Book remote) { UpdateResult result; remote.UseDbFieldsFrom(local); if (local.Title != (remote.Title ?? "Unknown") || local.ForeignBookId != remote.ForeignBookId || local.AuthorMetadata.Value.ForeignAuthorId != remote.AuthorMetadata.Value.ForeignAuthorId) { result = UpdateResult.UpdateTags; } else if (!local.Equals(remote)) { result = UpdateResult.Standard; } else { result = UpdateResult.None; } // Force update and fetch covers if images have changed so that we can write them into tags // if (remote.Images.Any() && !local.Images.SequenceEqual(remote.Images)) // { // _mediaCoverService.EnsureBookCovers(remote); // result = UpdateResult.UpdateTags; // } local.UseMetadataFrom(remote); local.AuthorMetadataId = remote.AuthorMetadata.Value.Id; local.LastInfoSync = DateTime.UtcNow; return result; } protected override UpdateResult MergeEntity(Book local, Book target, Book remote) { _logger.Warn($"Book {local} was merged with {remote} because the original was a duplicate."); // Update book ids for trackfiles var files = _mediaFileService.GetFilesByBook(local.Id); files.ForEach(x => x.EditionId = target.Editions.Value.Single(e => e.Monitored).Id); _mediaFileService.Update(files); // Update book ids for history var items = _historyService.GetByBook(local.Id, null); items.ForEach(x => x.BookId = target.Id); _historyService.UpdateMany(items); // Finally delete the old book _bookService.DeleteMany(new List { local }); return UpdateResult.UpdateTags; } protected override Book GetEntityByForeignId(Book local) { return _bookService.FindById(local.ForeignBookId); } protected override void SaveEntity(Book local) { // Use UpdateMany to avoid firing the book edited event _bookService.UpdateMany(new List { local }); } protected override void DeleteEntity(Book local, bool deleteFiles) { _bookService.DeleteBook(local.Id, true); } protected override List GetRemoteChildren(Book local, Book remote) { return remote.Editions.Value.DistinctBy(m => m.ForeignEditionId).ToList(); } protected override List GetLocalChildren(Book entity, List remoteChildren) { return _editionService.GetEditionsForRefresh(entity.Id, remoteChildren.Select(x => x.ForeignEditionId).ToList()); } protected override Tuple> GetMatchingExistingChildren(List existingChildren, Edition remote) { var existingChild = existingChildren.SingleOrDefault(x => x.ForeignEditionId == remote.ForeignEditionId); return Tuple.Create(existingChild, new List()); } protected override void PrepareNewChild(Edition child, Book entity) { child.BookId = entity.Id; child.Book = entity; } protected override void PrepareExistingChild(Edition local, Edition remote, Book entity) { local.BookId = entity.Id; local.Book = entity; remote.UseDbFieldsFrom(local); } protected override void AddChildren(List children) { // hack - add the chilren in refresh children so we can control monitored status } private void MonitorSingleEdition(SortedChildren children) { children.Old.ForEach(x => x.Monitored = false); var monitored = children.Future.Where(x => x.Monitored).ToList(); if (monitored.Count == 1) { return; } if (monitored.Count == 0) { monitored = children.Future; } if (monitored.Count == 0) { // there are no future children so nothing to do return; } var toMonitor = monitored.OrderByDescending(x => x.Id > 0 ? _mediaFileService.GetFilesByEdition(x.Id).Count : 0) .ThenByDescending(x => x.Ratings.Popularity).First(); monitored.ForEach(x => x.Monitored = false); toMonitor.Monitored = true; // force update of anything we've messed with var extraToUpdate = children.UpToDate.Where(x => monitored.Contains(x)); children.UpToDate = children.UpToDate.Except(extraToUpdate).ToList(); children.Updated.AddRange(extraToUpdate); Debug.Assert(!children.Future.Any() || children.Future.Count(x => x.Monitored) == 1, "one edition monitored"); } protected override bool RefreshChildren(SortedChildren localChildren, List remoteChildren, Author remoteData, bool forceChildRefresh, bool forceUpdateFileTags, DateTime? lastUpdate) { // make sure only one of the releases ends up monitored MonitorSingleEdition(localChildren); localChildren.All.ForEach(x => _logger.Trace($"release: {x} monitored: {x.Monitored}")); _editionService.InsertMany(localChildren.Added); return _refreshEditionService.RefreshEditionInfo(localChildren.Added, localChildren.Updated, localChildren.Merged, localChildren.Deleted, localChildren.UpToDate, remoteChildren, forceUpdateFileTags); } protected override void PublishEntityUpdatedEvent(Book entity) { // Fetch fresh from DB so all lazy loads are available _eventAggregator.PublishEvent(new BookUpdatedEvent(_bookService.GetBook(entity.Id))); } public bool RefreshBookInfo(List books, List remoteBooks, Author remoteData, bool forceBookRefresh, bool forceUpdateFileTags, DateTime? lastUpdate) { var updated = false; foreach (var book in books) { if (forceBookRefresh || _checkIfBookShouldBeRefreshed.ShouldRefresh(book)) { updated |= RefreshBookInfo(book, remoteBooks, remoteData, forceUpdateFileTags); } else { _logger.Debug("Skipping refresh of book: {0}", book.Title); } } return updated; } public bool RefreshBookInfo(Book book, List remoteBooks, Author remoteData, bool forceUpdateFileTags) { return RefreshEntityInfo(book, remoteBooks, remoteData, true, forceUpdateFileTags, null); } public bool RefreshBookInfo(Book book) { var data = GetSkyhookData(book); return RefreshBookInfo(book, data.Books, data, false); } public void Execute(BulkRefreshBookCommand message) { var books = _bookService.GetBooks(message.BookIds); foreach (var book in books) { RefreshBookInfo(book); } } public void Execute(RefreshBookCommand message) { if (message.BookId.HasValue) { var book = _bookService.GetBook(message.BookId.Value); RefreshBookInfo(book); } } } }