diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs index 879c184c32..23e49af68e 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs @@ -365,11 +365,15 @@ public void should_mark_as_imported_if_the_download_can_be_tracked_using_the_sou } [Test] - public void should_pass_book_override_when_remotebook_has_single_book() + public void should_pass_book_override_when_remotebook_has_single_valid_local_book() { var book = CreateBook(42); _trackedDownload.RemoteBook.Books = new List { book }; + Mocker.GetMock() + .Setup(s => s.GetBook(42)) + .Returns(book); + Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List @@ -388,6 +392,55 @@ public void should_pass_book_override_when_remotebook_has_single_book() Times.Once()); } + [Test] + public void should_not_pass_book_override_when_single_book_has_zero_id() + { + var book = CreateBook(0); + _trackedDownload.RemoteBook.Books = new List { book }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List()); + + Subject.Import(_trackedDownload); + + Mocker.GetMock() + .Verify(v => v.ProcessPath( + It.IsAny(), + ImportMode.Auto, + It.Is(o => o.Author == _trackedDownload.RemoteBook.Author && o.Book == null), + It.IsAny()), + Times.Once()); + + Mocker.GetMock() + .Verify(s => s.GetBook(It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_pass_book_override_when_single_book_not_found_in_db() + { + var book = CreateBook(99); + _trackedDownload.RemoteBook.Books = new List { book }; + + Mocker.GetMock() + .Setup(s => s.GetBook(99)) + .Returns((Book)null); + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List()); + + Subject.Import(_trackedDownload); + + Mocker.GetMock() + .Verify(v => v.ProcessPath( + It.IsAny(), + ImportMode.Auto, + It.Is(o => o.Author == _trackedDownload.RemoteBook.Author && o.Book == null), + It.IsAny()), + Times.Once()); + } + [Test] public void should_not_pass_book_override_when_remotebook_has_multiple_books() { diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/CandidateServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/CandidateServiceFixture.cs index 4bd7b03d10..46ab9f970a 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/CandidateServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/CandidateServiceFixture.cs @@ -1,7 +1,12 @@ using System.Collections.Generic; +using System.Linq; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Core.Books; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.BookImport; using NzbDrone.Core.MediaFiles.BookImport.Identification; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource.Goodreads; @@ -13,6 +18,70 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification [TestFixture] public class CandidateServiceFixture : CoreTest { + private LocalEdition BuildLocalEdition(string bookTitle = "Book", string author = "Author") + { + return new LocalEdition + { + LocalBooks = new List + { + new LocalBook + { + FileTrackInfo = new ParsedTrackInfo + { + Authors = new List { author }, + BookTitle = bookTitle + } + } + } + }; + } + + [Test] + public void should_fall_back_to_author_search_when_book_override_returns_no_candidates() + { + var author = new Author { Id = 1, AuthorMetadataId = 10 }; + var book = new Book { Id = 99 }; + var idOverrides = new IdentificationOverrides { Author = author, Book = book }; + + // Book override path returns nothing (stale book) + Mocker.GetMock() + .Setup(s => s.GetEditionsByBook(99)) + .Returns(new List()); + + // Author fallback should find candidates + var edition = new Edition + { + Id = 5, + BookId = 50, + ForeignEditionId = "edition-1", + Ratings = new Ratings { Votes = 1, Value = 1.0m }, + Book = new LazyLoaded(new Book + { + Id = 50, + ForeignBookId = "foreign-50", + Author = new LazyLoaded(author) + }) + }; + + Mocker.GetMock() + .Setup(s => s.GetCandidates(10, "Book")) + .Returns(new List { new Book { Id = 50 } }); + + Mocker.GetMock() + .Setup(s => s.GetEditionsByBook(50)) + .Returns(new List { edition }); + + Mocker.GetMock() + .Setup(s => s.GetFilesByBook(It.IsAny())) + .Returns(new List()); + + var localEdition = BuildLocalEdition(); + var result = Subject.GetDbCandidatesFromTags(localEdition, idOverrides, false); + + result.Should().HaveCount(1); + result.First().Edition.Id.Should().Be(5); + } + [Test] public void should_not_throw_on_goodreads_exception() { diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index 4b87ca1344..3549251df0 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.History; +using NzbDrone.Core.Books; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.BookImport; using NzbDrone.Core.MediaFiles.Events; @@ -28,6 +29,7 @@ public class CompletedDownloadService : ICompletedDownloadService private readonly IProvideImportItemService _provideImportItemService; private readonly IDownloadedBooksImportService _downloadedTracksImportService; private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; + private readonly IBookService _bookService; private readonly Logger _logger; public CompletedDownloadService(IEventAggregator eventAggregator, @@ -35,6 +37,7 @@ public CompletedDownloadService(IEventAggregator eventAggregator, IProvideImportItemService provideImportItemService, IDownloadedBooksImportService downloadedTracksImportService, ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported, + IBookService bookService, Logger logger) { _eventAggregator = eventAggregator; @@ -42,6 +45,7 @@ public CompletedDownloadService(IEventAggregator eventAggregator, _provideImportItemService = provideImportItemService; _downloadedTracksImportService = downloadedTracksImportService; _trackedDownloadAlreadyImported = trackedDownloadAlreadyImported; + _bookService = bookService; _logger = logger; } @@ -203,11 +207,17 @@ private IdentificationOverrides BuildIdentificationOverrides(Parser.Model.Remote Author = remoteBook.Author }; - // Only set Book override when the grab targeted exactly one book, - // so we don't incorrectly force multi-book/omnibus downloads to a single book. + // Only set Book override when the grab targeted exactly one book + // AND we can confirm the book exists in the local database. + // RemoteBook.Books may contain stale or unmapped objects that would + // cause CandidateService.GetDbCandidatesByBook to return zero candidates. if (remoteBook.Books != null && remoteBook.Books.Count == 1) { - overrides.Book = remoteBook.Books.First(); + var candidate = remoteBook.Books.First(); + if (candidate.Id > 0 && _bookService.GetBook(candidate.Id) != null) + { + overrides.Book = candidate; + } } return overrides; diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs index c188498608..976885bec0 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs @@ -80,6 +80,14 @@ public List GetDbCandidatesFromTags(LocalEdition localEdition, { candidateReleases = GetDbCandidatesByBook(idOverrides.Book, includeExisting); } + + // If the book override produced no candidates (e.g. stale/invalid book), + // fall back to author-scoped search so import can still proceed. + if (!candidateReleases.Any() && idOverrides.Author != null) + { + _logger.Debug("Book override returned no candidates, falling back to author search"); + candidateReleases = GetDbCandidatesByAuthor(localEdition, idOverrides.Author, includeExisting); + } } else if (idOverrides?.Author != null) { diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs index 79a40bfb29..8114bf1305 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs @@ -5,6 +5,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Books; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.MediaFiles.BookImport; @@ -20,6 +21,7 @@ public class DownloadedBooksCommandService : IExecute 0 && _bookService.GetBook(candidate.Id) != null) + { + overrides.Book = candidate; + } } return overrides;