From b787e027dd3a751b21c685eb4ce928ea13d74402 Mon Sep 17 00:00:00 2001 From: benjitobz Date: Mon, 31 Aug 2026 00:32:11 -0400 Subject: [PATCH 1/4] Make Goodreads bookshelf refresh interval configurable --- .../Goodreads/Bookshelf/GoodreadsBookshelf.cs | 14 +++++++++++++- .../GoodreadsBookshelfImportListSettings.cs | 7 +++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs index aed57e1e..05bf91fd 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 = 15; + 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(minutes); + } + } 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..77ca482d 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs @@ -17,6 +17,9 @@ 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(1) + .WithMessage("Refresh interval must be at least 1 minute"); this.AddDualMediaRules(); } } @@ -31,6 +34,7 @@ public GoodreadsBookshelfImportListSettings() BookshelfIds = new string[] { }; MonitorAudiobooks = true; MonitorEbooks = true; + RefreshIntervalMinutes = 15; } public string BaseUrl { get; set; } @@ -83,6 +87,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. 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)) From 253d67faf1521b2ae16295325729c82c5de0b735 Mon Sep 17 00:00:00 2001 From: benjitobz Date: Thu, 3 Sep 2026 13:04:31 -0400 Subject: [PATCH 2/4] Keep the 12 hour default when the refresh interval is unset The configurable interval defaults to 720 minutes, the same 12 hours the hard-coded MinRefreshInterval used, so existing lists and new ones behave exactly as before until a user chooses a shorter interval. --- .../ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs | 2 +- .../Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs index 05bf91fd..56d27fb3 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs @@ -61,7 +61,7 @@ public GoodreadsBookshelf(IImportListStatusService importListStatusService, _rootFolderSettingsResolver = rootFolderSettingsResolver; } - private const int DefaultRefreshIntervalMinutes = 15; + private const int DefaultRefreshIntervalMinutes = 720; public override string Name => "Goodreads Bookshelves"; public override ImportListType ListType => ImportListType.Goodreads; diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs index 77ca482d..211c783e 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs @@ -34,7 +34,7 @@ public GoodreadsBookshelfImportListSettings() BookshelfIds = new string[] { }; MonitorAudiobooks = true; MonitorEbooks = true; - RefreshIntervalMinutes = 15; + RefreshIntervalMinutes = 720; } public string BaseUrl { get; set; } From c64aad3d45848b6c4c65ba06d6f4514d29dd6aff Mon Sep 17 00:00:00 2001 From: benjitobz Date: Wed, 16 Sep 2026 11:08:42 -0400 Subject: [PATCH 3/4] fix(monitoring): keep specific-book adds from monitoring the whole catalogue A specific-book add (an import list set to Specific Book, or a pending author import carrying BooksToMonitor) seeds the book rows by provider ID at insert time and deliberately leaves the per-media one-time modes null. When the add also asked to search for missing books, ApplyMonitoringConfig still created AddAuthorOptions for the search flag. Its generic Monitor field was never set, so it kept the enum default - MonitorTypes.All. After the author's first disk scan, AuthorScannedHandler found no media-specific mode and fell back to that Monitor value, monitoring every book of the author in both formats and then running a missing-book search over all of them. Adding one Goodreads shelf book by Danny McBride this way monitored and searched two unrelated same-name catalogue entries. Record MonitorTypes.SpecificBook on the fallback when a specific-book add is the reason no media-specific mode exists, and have BookMonitoredService leave the seeded rows unchanged for that mode instead of throwing. --- ...aryServiceSpecificBookMonitoringFixture.cs | 60 ++++++++++++++ ...BookMonitoredServiceSpecificBookFixture.cs | 79 +++++++++++++++++++ .../Books/Services/AuthorLibraryService.cs | 8 ++ .../Books/Services/BookMonitoredService.cs | 3 + 4 files changed, 150 insertions(+) create mode 100644 src/Chaptarr.Core.Test/Books/BookMonitoredServiceSpecificBookFixture.cs 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/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(); } From ec2a8c6f386e523d56f317eb687b97870c99a1d7 Mon Sep 17 00:00:00 2001 From: benjitobz Date: Wed, 16 Sep 2026 11:35:08 -0400 Subject: [PATCH 4/4] Require a Goodreads refresh interval of at least 15 minutes The refresh interval accepted anything from 1 minute, which would poll Goodreads far more often than a shelf changes. Validation now rejects values below 15 minutes, and the scheduling interval is clamped to the same floor so a value saved before this rule cannot take effect either. An unset interval still falls back to the 12 hour default. --- .../GoodreadsBookshelfImportListFixture.cs | 45 +++++++++++++++++++ .../Goodreads/Bookshelf/GoodreadsBookshelf.cs | 2 +- .../GoodreadsBookshelfImportListSettings.cs | 8 ++-- 3 files changed, 51 insertions(+), 4 deletions(-) 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/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs index 56d27fb3..6493d7ce 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelf.cs @@ -73,7 +73,7 @@ public override TimeSpan MinRefreshInterval ? settings.RefreshIntervalMinutes : DefaultRefreshIntervalMinutes; - return TimeSpan.FromMinutes(minutes); + return TimeSpan.FromMinutes(Math.Max(minutes, GoodreadsBookshelfImportListSettings.MinimumRefreshIntervalMinutes)); } } diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs index 211c783e..38ba1aed 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Bookshelf/GoodreadsBookshelfImportListSettings.cs @@ -18,14 +18,16 @@ public GoodreadsBookshelfImportListSettingsValidator() .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(1) - .WithMessage("Refresh interval must be at least 1 minute"); + .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() @@ -87,7 +89,7 @@ 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. Short intervals mean more requests to Goodreads.", Advanced = true)] + [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()