Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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<Book>
{
BuildAudiobook("Selected Book", "hc:1001"),
BuildAudiobook("Other Book", "hc:1002")
},
Series = new List<Series>()
};

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<string> { "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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IBookService, BookServiceProxy>();
((BookServiceProxy)(object)bookService).Books = new List<Book> { requested, sibling, siblingEbook };
var authorService = DispatchProxy.Create<IAuthorService, AuthorServiceProxy>();

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<Book> 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<Book>();
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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IQualityProfileService>(() => new StubQualityProfileService()),
metadataProfileService: new Lazy<IMetadataProfileService>(() => new StubMetadataProfileService()),
tagService: new Lazy<ITagService>(() => 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()
{
Expand Down
8 changes: 8 additions & 0 deletions src/NzbDrone.Core/Books/Services/AuthorLibraryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/NzbDrone.Core/Books/Services/BookMonitoredService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImportListItemInfo> Fetch()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -31,6 +36,7 @@ public GoodreadsBookshelfImportListSettings()
BookshelfIds = new string[] { };
MonitorAudiobooks = true;
MonitorEbooks = true;
RefreshIntervalMinutes = 720;
}

public string BaseUrl { get; set; }
Expand Down Expand Up @@ -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<int> 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))
Expand Down