diff --git a/src/Chaptarr.Core.Test/Books/AuthorLibraryServiceSpecificBookMonitoringFixture.cs b/src/Chaptarr.Core.Test/Books/AuthorLibraryServiceSpecificBookMonitoringFixture.cs index 3fa690de..78df2734 100644 --- a/src/Chaptarr.Core.Test/Books/AuthorLibraryServiceSpecificBookMonitoringFixture.cs +++ b/src/Chaptarr.Core.Test/Books/AuthorLibraryServiceSpecificBookMonitoringFixture.cs @@ -672,6 +672,66 @@ public async Task add_author_should_monitor_selected_audiobook_books_from_import }); } + [Test] + public async Task specific_book_import_with_search_should_not_leave_an_all_books_fallback_for_the_scan() + { + var remoteAuthor = new Author + { + Name = "Shelf Author", + Books = new List + { + BuildAudiobook("Selected Book", "hc:1001"), + BuildAudiobook("Other Book", "hc:1002") + }, + Series = new List() + }; + + var service = new AuthorLibraryService( + authorService: new StubAuthorService(), + authorInfo: new StubAuthorInfo(remoteAuthor), + bookService: new StubBookService(), + refreshSeriesService: null, + editionService: new StubEditionService(), + narratorLinkService: null, + metadataProfileService: new StubMetadataProfileService(), + qualityProfileService: new TestQualityProfileService(), + authorPathBuilder: new StubAuthorPathBuilder(), + rootFolderService: new StubRootFolderService(BuildAudiobookRoot("/audiobooks")), + commandQueueManager: null, + eventAggregator: new StubEventAggregator(), + pendingImportService: null, + mainDatabase: null, + importListExclusionService: null, + editionMetadataProfileFilter: new EditionMetadataProfileFilter(new TestTermMatcherService()), + syncMetadataService: null, + logger: LogManager.GetCurrentClassLogger(), + editionSelector: new EditionSelector(LogManager.GetCurrentClassLogger())); + + var addedAuthor = await service.AddAuthorAsync("hc:author-1", new MonitoringConfig + { + AuthorName = remoteAuthor.Name, + CreateAudiobook = true, + CreateEbook = false, + AudiobookQualityProfileId = 2, + AudiobookMetadataProfileId = 1, + AudiobookRootFolderPath = "/audiobooks", + AudiobookMonitored = true, + AudiobookMonitorNewItems = NewItemMonitorTypes.All, + AudiobookMonitorExistingMode = MonitorTypes.SpecificBook, + AudiobookBooksToMonitor = new List { "hc:1001" }, + SearchForMissingBooks = true + }); + + Assert.Multiple(() => + { + Assert.That(addedAuthor.AddOptions, Is.Not.Null); + Assert.That(addedAuthor.AddOptions.SearchForMissingBooks, Is.True); + Assert.That(addedAuthor.AddOptions.AudiobookMonitor, Is.Null); + Assert.That(addedAuthor.AddOptions.EbookMonitor, Is.Null); + Assert.That(addedAuthor.AddOptions.Monitor, Is.EqualTo(MonitorTypes.SpecificBook)); + }); + } + [Test] public async Task all_initial_mode_should_not_be_narrowed_by_the_requested_work_rescue_id() { diff --git a/src/Chaptarr.Core.Test/Books/BookMonitoredServiceSpecificBookFixture.cs b/src/Chaptarr.Core.Test/Books/BookMonitoredServiceSpecificBookFixture.cs new file mode 100644 index 00000000..76baba2a --- /dev/null +++ b/src/Chaptarr.Core.Test/Books/BookMonitoredServiceSpecificBookFixture.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using NLog; +using NUnit.Framework; +using NzbDrone.Core.Books; + +namespace Chaptarr.Core.Test.Books +{ + [TestFixture] + public class BookMonitoredServiceSpecificBookFixture + { + [Test] + public void specific_book_mode_should_leave_the_seeded_rows_unchanged() + { + var author = new Author { Id = 99, Name = "Shelf Author" }; + var requested = new Book { Id = 1, AuthorId = 99, MediaType = BookMediaType.Audiobook, AudiobookMonitored = true }; + var sibling = new Book { Id = 2, AuthorId = 99, MediaType = BookMediaType.Audiobook, AudiobookMonitored = false }; + var siblingEbook = new Book { Id = 3, AuthorId = 99, MediaType = BookMediaType.Ebook, EbookMonitored = false }; + + var bookService = DispatchProxy.Create(); + ((BookServiceProxy)(object)bookService).Books = new List { requested, sibling, siblingEbook }; + var authorService = DispatchProxy.Create(); + + var subject = new BookMonitoredService(authorService, bookService, LogManager.GetCurrentClassLogger()); + + subject.SetBookMonitoredStatus(author, new AddAuthorOptions + { + Monitor = MonitorTypes.SpecificBook, + SearchForMissingBooks = true + }); + + Assert.Multiple(() => + { + Assert.That(requested.AudiobookMonitored, Is.True); + Assert.That(sibling.AudiobookMonitored, Is.False); + Assert.That(siblingEbook.EbookMonitored, Is.False); + Assert.That(((AuthorServiceProxy)(object)authorService).Updated, Is.SameAs(author)); + }); + } + + public class BookServiceProxy : DispatchProxy + { + public List Books { get; set; } = new(); + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + switch (targetMethod?.Name) + { + case nameof(IBookService.GetBooksByAuthor): + return Books.ToList(); + case nameof(IBookService.GetAuthorBooksWithFiles): + return new List(); + case nameof(IBookService.UpdateManyWithLifecycle): + return targetMethod.ReturnType == typeof(void) ? null : Activator.CreateInstance(targetMethod.ReturnType); + default: + throw new NotImplementedException(targetMethod?.Name); + } + } + } + + public class AuthorServiceProxy : DispatchProxy + { + public Author Updated { get; private set; } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (targetMethod?.Name == nameof(IAuthorService.UpdateAuthor)) + { + Updated = (Author)args[0]; + return args[0]; + } + + throw new NotImplementedException(targetMethod?.Name); + } + } + } +} diff --git a/src/Chaptarr.Core.Test/ImportLists/Goodreads/GoodreadsBookshelfImportListFixture.cs b/src/Chaptarr.Core.Test/ImportLists/Goodreads/GoodreadsBookshelfImportListFixture.cs index c75f7a01..ef37c794 100644 --- a/src/Chaptarr.Core.Test/ImportLists/Goodreads/GoodreadsBookshelfImportListFixture.cs +++ b/src/Chaptarr.Core.Test/ImportLists/Goodreads/GoodreadsBookshelfImportListFixture.cs @@ -948,6 +948,51 @@ public void settings_should_require_at_least_one_media_type() Assert.That(result.IsValid, Is.False); } + [TestCase(1, false)] + [TestCase(14, false)] + [TestCase(15, true)] + [TestCase(720, true)] + public void settings_should_require_a_refresh_interval_of_at_least_15_minutes(int minutes, bool valid) + { + var settings = new GoodreadsBookshelfImportListSettings + { + UserId = "12345678", + BookshelfIds = new[] { "to-read" }, + AudiobookRootFolderPath = "/audiobooks", + EbookRootFolderPath = "/books", + RefreshIntervalMinutes = minutes + }; + + Assert.That(settings.Validate().IsValid, Is.EqualTo(valid)); + } + + [TestCase(5, 15)] + [TestCase(15, 15)] + [TestCase(40, 40)] + [TestCase(0, 720)] + public void refresh_interval_should_never_drop_below_15_minutes(int configured, int expected) + { + var importList = new GoodreadsBookshelf( + importListStatusService: new StubImportListStatusService(), + configService: null, + parsingService: null, + httpClient: null, + qualityProfileService: new Lazy(() => new StubQualityProfileService()), + metadataProfileService: new Lazy(() => new StubMetadataProfileService()), + tagService: new Lazy(() => new StubTagService()), + rootFolderService: new StubRootFolderService(), + rootFolderSettingsResolver: new StubRootFolderSettingsResolver(), + logger: LogManager.GetCurrentClassLogger()) + { + Definition = new ImportListDefinition + { + Settings = new GoodreadsBookshelfImportListSettings { RefreshIntervalMinutes = configured } + } + }; + + Assert.That(importList.MinRefreshInterval, Is.EqualTo(TimeSpan.FromMinutes(expected))); + } + [Test] public void settings_should_reject_negative_import_limit() { diff --git a/src/NzbDrone.Core/Books/Services/AuthorLibraryService.cs b/src/NzbDrone.Core/Books/Services/AuthorLibraryService.cs index b9954cfe..c926f1a4 100644 --- a/src/NzbDrone.Core/Books/Services/AuthorLibraryService.cs +++ b/src/NzbDrone.Core/Books/Services/AuthorLibraryService.cs @@ -1812,6 +1812,14 @@ private void ApplyMonitoringConfig(Author author, MonitoringConfig config) author.AddOptions.AudiobookMonitor = audiobookInitialMode; author.AddOptions.EbookMonitor = ebookInitialMode; author.AddOptions.SearchForMissingBooks = config.SearchForMissingBooks == true; + + var specificBookRequested = + (config.CreateAudiobook && config.AudiobookMonitorExistingMode == MonitorTypes.SpecificBook) || + (config.CreateEbook && config.EbookMonitorExistingMode == MonitorTypes.SpecificBook); + if (!audiobookInitialMode.HasValue && !ebookInitialMode.HasValue && specificBookRequested) + { + author.AddOptions.Monitor = MonitorTypes.SpecificBook; + } } // Apply audiobook settings only when this add request configured audiobook support. diff --git a/src/NzbDrone.Core/Books/Services/BookMonitoredService.cs b/src/NzbDrone.Core/Books/Services/BookMonitoredService.cs index f90f2261..356bc961 100644 --- a/src/NzbDrone.Core/Books/Services/BookMonitoredService.cs +++ b/src/NzbDrone.Core/Books/Services/BookMonitoredService.cs @@ -112,6 +112,9 @@ public void SetBookMonitoredStatus(Author author, MonitoringOptions monitoringOp ToggleBooksMonitoredState(books, false); ToggleBooksMonitoredState(books.OrderBy(e => e.ReleaseDate).Take(1), true); break; + case MonitorTypes.SpecificBook: + _logger.Debug("Specific-book add: book rows were monitored by provider ID when inserted, leaving them unchanged"); + break; default: throw new ArgumentOutOfRangeException(); } diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs index aed57e1e..6493d7ce 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs @@ -61,9 +61,21 @@ public GoodreadsBookshelf(IImportListStatusService importListStatusService, _rootFolderSettingsResolver = rootFolderSettingsResolver; } + private const int DefaultRefreshIntervalMinutes = 720; + public override string Name => "Goodreads Bookshelves"; public override ImportListType ListType => ImportListType.Goodreads; - public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(12); + public override TimeSpan MinRefreshInterval + { + get + { + var minutes = Definition?.Settings is GoodreadsBookshelfImportListSettings settings && settings.RefreshIntervalMinutes > 0 + ? settings.RefreshIntervalMinutes + : DefaultRefreshIntervalMinutes; + + return TimeSpan.FromMinutes(Math.Max(minutes, GoodreadsBookshelfImportListSettings.MinimumRefreshIntervalMinutes)); + } + } public override IList Fetch() { diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs index 9159df86..38ba1aed 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs @@ -17,12 +17,17 @@ public GoodreadsBookshelfImportListSettingsValidator() .Must(GoodreadsUserIdParser.IsValidUserId) .WithMessage("Goodreads user ID must be a numeric ID (or a profile URL containing it)"); RuleFor(c => c.BookshelfIds).NotEmpty(); + RuleFor(c => c.RefreshIntervalMinutes) + .GreaterThanOrEqualTo(GoodreadsBookshelfImportListSettings.MinimumRefreshIntervalMinutes) + .WithMessage($"Refresh interval must be at least {GoodreadsBookshelfImportListSettings.MinimumRefreshIntervalMinutes} minutes"); this.AddDualMediaRules(); } } public class GoodreadsBookshelfImportListSettings : IGoodreadsDualMediaImportListSettings { + public const int MinimumRefreshIntervalMinutes = 15; + private static readonly GoodreadsBookshelfImportListSettingsValidator Validator = new(); public GoodreadsBookshelfImportListSettings() @@ -31,6 +36,7 @@ public GoodreadsBookshelfImportListSettings() BookshelfIds = new string[] { }; MonitorAudiobooks = true; MonitorEbooks = true; + RefreshIntervalMinutes = 720; } public string BaseUrl { get; set; } @@ -83,6 +89,9 @@ public GoodreadsBookshelfImportListSettings() [FieldDefinition(12, Label = "Ebook Tags", Type = FieldType.TagSelect, SelectOptionsProviderAction = "getTags", HelpText = "Optional: tags to apply when importing ebooks from these shelves.")] public List EbookTags { get; set; } = new(); + [FieldDefinition(13, Label = "Refresh Interval (Minutes)", Type = FieldType.Number, HelpText = "How often these shelves are checked for new books, in minutes (minimum 15). Short intervals mean more requests to Goodreads.", Advanced = true)] + public int RefreshIntervalMinutes { get; set; } + public NzbDroneValidationResult Validate() { if (!UserId.IsNullOrWhiteSpace() && GoodreadsUserIdParser.TryParse(UserId, out var normalized))