From 622c2dc09bb4bc85168c6a985f008244fae75b9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 20:47:41 +0000 Subject: [PATCH 1/9] Add configurable UI setting for import match threshold Replace the hard-coded 0.50 distance threshold in CloseBookMatchSpecification with a user-configurable setting (BookImportMinimumMatchPercent). - Add BookImportMinimumMatchPercent to IConfigService/ConfigService (default: 50) - Add to MediaManagement API resource with validation (range: 50-99) - Wire into CloseBookMatchSpecification: threshold = 1.0 - (percent / 100.0) - Add UI field in Media Management > Importing section - Add localization keys for label and help text - Add unit tests for threshold conversion and behavior Default behavior is preserved: 50% minimum match = 0.50 internal threshold. https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .../MediaManagement/MediaManagement.js | 21 +++ .../CloseBookMatchSpecificationFixture.cs | 171 ++++++++++++++++++ .../Configuration/ConfigService.cs | 7 + .../Configuration/IConfigService.cs | 1 + src/NzbDrone.Core/Localization/Core/en.json | 2 + .../CloseAlbumMatchSpecification.cs | 21 ++- .../Config/MediaManagementConfigController.cs | 1 + .../Config/MediaManagementConfigResource.cs | 2 + 8 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index e97a816782..18edc53133 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -211,6 +211,27 @@ class MediaManagement extends Component { /> + + + {translate('MinimumAutoImportTitleMatch')} + + + + + { settings.importExtraFiles.value ? + { + private LocalEdition _localEdition; + + [SetUp] + public void Setup() + { + _localEdition = new LocalEdition + { + Distance = new Distance(), + NewDownload = true + }; + + Mocker.GetMock() + .Setup(s => s.BookImportMinimumMatchPercent) + .Returns(50); + } + + private void GivenNewDownload(bool newDownload) + { + _localEdition.NewDownload = newDownload; + } + + private void GivenMinimumMatchPercent(int percent) + { + Mocker.GetMock() + .Setup(s => s.BookImportMinimumMatchPercent) + .Returns(percent); + } + + private void GivenDistance(string key, double value) + { + _localEdition.Distance = new Distance(); + _localEdition.Distance.Add(key, value); + } + + [Test] + public void should_accept_when_distance_is_zero() + { + GivenDistance("book", 0.0); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_accept_when_distance_is_within_default_threshold() + { + // 0.40 normalized distance = 60% match, which is > 50% minimum + GivenDistance("book", 0.40); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_reject_when_distance_exceeds_default_threshold() + { + // With default 50%, threshold = 0.50 + // A full penalty on book_id (weight 5.0) gives normalized distance = 1.0 + _localEdition.Distance = new Distance(); + _localEdition.Distance.Add("book_id", 1.0); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeFalse(); + } + + [Test] + public void default_value_should_preserve_existing_behavior() + { + // Default is 50%, which means threshold = 0.50 + // This is the same as the previous hard-coded value + GivenMinimumMatchPercent(50); + GivenDistance("book", 0.40); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_stricter_with_higher_percentage() + { + // 90% minimum match = threshold of 0.10 + GivenMinimumMatchPercent(90); + + // distance of 0.20 > threshold of 0.10, should reject + GivenDistance("book", 0.20); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_accept_strict_threshold_when_match_is_good() + { + // 90% minimum match = threshold of 0.10 + GivenMinimumMatchPercent(90); + + // distance of 0.05 < threshold of 0.10, should accept + GivenDistance("book", 0.05); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_looser_with_lower_percentage() + { + // 50% minimum match = threshold of 0.50 + GivenMinimumMatchPercent(50); + + // distance of 0.40 < threshold of 0.50, should accept + GivenDistance("book", 0.40); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); + } + + [Test] + public void ninety_percent_produces_threshold_of_point_one() + { + // 90% minimum match should produce internal threshold of 0.10 + GivenMinimumMatchPercent(90); + + // distance of 0.10 equals threshold of 0.10, should accept (not >) + GivenDistance("book", 0.10); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); + } + + [Test] + public void ninety_percent_rejects_just_above_threshold() + { + GivenMinimumMatchPercent(90); + + // distance of 0.11 > threshold of 0.10, should reject + GivenDistance("book", 0.11); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_use_existing_library_path_when_not_new_download() + { + GivenNewDownload(false); + GivenMinimumMatchPercent(50); + + // Uses NormalizedDistanceExcluding for existing library imports + GivenDistance("book", 0.40); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_reject_existing_library_import_when_above_threshold() + { + GivenNewDownload(false); + GivenMinimumMatchPercent(50); + + _localEdition.Distance = new Distance(); + _localEdition.Distance.Add("book_id", 1.0); + + Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 41fb7dc522..34a827bd67 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -221,6 +221,13 @@ public string ExtraFileExtensions set { SetValue("ExtraFileExtensions", value); } } + public int BookImportMinimumMatchPercent + { + get { return GetValueInt("BookImportMinimumMatchPercent", 50); } + + set { SetValue("BookImportMinimumMatchPercent", value); } + } + public bool WatchLibraryForChanges { get { return GetValueBoolean("WatchLibraryForChanges", true); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 1170f30de0..63e6466edc 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -34,6 +34,7 @@ public interface IConfigService bool CopyUsingHardlinks { get; set; } bool ImportExtraFiles { get; set; } string ExtraFileExtensions { get; set; } + int BookImportMinimumMatchPercent { get; set; } bool WatchLibraryForChanges { get; set; } RescanAfterRefreshType RescanAfterRefresh { get; set; } AllowFingerprinting AllowFingerprinting { get; set; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 93207db2ff..d450296769 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -584,6 +584,8 @@ "MinimumAgeHelpText": "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider.", "MinimumCustomFormatScore": "Minimum Custom Format Score", "MinimumCustomFormatScoreHelpText": "Minimum Custom Format Score required to bypass delay for the preferred protocol", + "MinimumAutoImportTitleMatch": "Minimum Auto-Import Title Match (%)", + "MinimumAutoImportTitleMatchHelpText": "Higher values are stricter. Increase this if similar titles by the same author are being mismatched during import.", "MinimumFreeSpace": "Minimum Free Space", "MinimumFreeSpaceWhenImportingHelpText": "Prevent import if it would leave less than this amount of disk space available", "MinimumLimits": "Minimum Limits", diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs index af6cffaf47..38395dc9ea 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using NLog; +using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Parser.Model; @@ -8,16 +9,18 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Specifications { public class CloseBookMatchSpecification : IImportDecisionEngineSpecification { - private const double _bookThreshold = 0.50; + private readonly IConfigService _configService; private readonly Logger _logger; - public CloseBookMatchSpecification(Logger logger) + public CloseBookMatchSpecification(IConfigService configService, Logger logger) { + _configService = configService; _logger = logger; } public Decision IsSatisfiedBy(LocalEdition item, DownloadClientItem downloadClientItem) { + var bookThreshold = 1.0 - (_configService.BookImportMinimumMatchPercent / 100.0); double dist; string reasons; @@ -26,10 +29,10 @@ public Decision IsSatisfiedBy(LocalEdition item, DownloadClientItem downloadClie { dist = item.Distance.NormalizedDistance(); reasons = item.Distance.Reasons; - if (dist > _bookThreshold) + if (dist > bookThreshold) { - _logger.Debug($"Book match is not close enough: {dist} vs {_bookThreshold} {reasons}. Skipping {item}"); - return Decision.Reject($"Book match is not close enough: {1 - dist:P1} vs {1 - _bookThreshold:P0} {reasons}"); + _logger.Debug($"Book match is not close enough: {dist} vs {bookThreshold} {reasons}. Skipping {item}"); + return Decision.Reject($"Book match is not close enough: {1 - dist:P1} vs {1 - bookThreshold:P0} {reasons}"); } } @@ -39,14 +42,14 @@ public Decision IsSatisfiedBy(LocalEdition item, DownloadClientItem downloadClie // get book distance ignoring whether tracks are missing dist = item.Distance.NormalizedDistanceExcluding(new List { "missing_tracks", "unmatched_tracks" }); reasons = item.Distance.Reasons; - if (dist > _bookThreshold) + if (dist > bookThreshold) { - _logger.Debug($"Book match is not close enough: {dist} vs {_bookThreshold} {reasons}. Skipping {item}"); - return Decision.Reject($"Book match is not close enough: {1 - dist:P1} vs {1 - _bookThreshold:P0} {reasons}"); + _logger.Debug($"Book match is not close enough: {dist} vs {bookThreshold} {reasons}. Skipping {item}"); + return Decision.Reject($"Book match is not close enough: {1 - dist:P1} vs {1 - bookThreshold:P0} {reasons}"); } } - _logger.Debug($"Accepting release {item}: dist {dist} vs {_bookThreshold} {reasons}"); + _logger.Debug($"Accepting release {item}: dist {dist} vs {bookThreshold} {reasons}"); return Decision.Accept(); } } diff --git a/src/Readarr.Api.V1/Config/MediaManagementConfigController.cs b/src/Readarr.Api.V1/Config/MediaManagementConfigController.cs index c3df96c314..a779fe20df 100644 --- a/src/Readarr.Api.V1/Config/MediaManagementConfigController.cs +++ b/src/Readarr.Api.V1/Config/MediaManagementConfigController.cs @@ -33,6 +33,7 @@ public MediaManagementConfigController(IConfigService configService, SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx)); SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100); + SharedValidator.RuleFor(c => c.BookImportMinimumMatchPercent).InclusiveBetween(50, 99); } protected override MediaManagementConfigResource ToResource(IConfigService model) diff --git a/src/Readarr.Api.V1/Config/MediaManagementConfigResource.cs b/src/Readarr.Api.V1/Config/MediaManagementConfigResource.cs index 81e781be52..5a4e894863 100644 --- a/src/Readarr.Api.V1/Config/MediaManagementConfigResource.cs +++ b/src/Readarr.Api.V1/Config/MediaManagementConfigResource.cs @@ -27,6 +27,7 @@ public class MediaManagementConfigResource : RestResource public bool CopyUsingHardlinks { get; set; } public bool ImportExtraFiles { get; set; } public string ExtraFileExtensions { get; set; } + public int BookImportMinimumMatchPercent { get; set; } } public static class MediaManagementConfigResourceMapper @@ -55,6 +56,7 @@ public static MediaManagementConfigResource ToResource(IConfigService model) CopyUsingHardlinks = model.CopyUsingHardlinks, ImportExtraFiles = model.ImportExtraFiles, ExtraFileExtensions = model.ExtraFileExtensions, + BookImportMinimumMatchPercent = model.BookImportMinimumMatchPercent, }; } } From e555fc8be0c97e65e1578eed9c6b47cb77a28e44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 21:10:59 +0000 Subject: [PATCH 2/9] Remove unused Moq using directive from test fixture The repo treats IDE0005 (unnecessary usings) as build errors. https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .../Specifications/CloseBookMatchSpecificationFixture.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs index 670ae4edec..2713e38f33 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs @@ -1,5 +1,4 @@ using FluentAssertions; -using Moq; using NUnit.Framework; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.BookImport.Identification; From 147a11e47e9f4270609bc2aca8f967b5f94bc9b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 22:25:58 +0000 Subject: [PATCH 3/9] Thread tracked remote book context through completed-download import Completed-download import previously discarded specific book context from TrackedDownload.RemoteBook and only passed Author into the identification pipeline. This forced a fuzzy re-identification among all books by that author, causing mis-matches for similar titles (e.g. issue #3 vs issue #6 of the same series). Now, when a completed download was grabbed for a specific single book, that book context is threaded through IdentificationOverrides into CandidateService, which already supports Book-level candidate restriction. Multi-book/omnibus downloads fall back to author-only scoping. Null RemoteBook preserves existing behavior exactly. Changes: - CompletedDownloadService.Import: build IdentificationOverrides from RemoteBook (Author + single Book when available) - DownloadedBooksCommandService: same override building for external scan commands - DownloadedBooksImportService: accept IdentificationOverrides instead of Author, thread through ProcessFolder/ProcessFile - Tests: update existing mocks, add 4 new tests covering single-book, multi-book, null-remote, and empty-books override scenarios https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .../ImportFixture.cs | 116 ++++++++++++++++-- .../DownloadedAlbumsCommandServiceFixture.cs | 4 +- .../DownloadedTracksImportServiceFixture.cs | 6 +- .../Download/CompletedDownloadService.cs | 25 +++- .../DownloadedBooksCommandService.cs | 24 +++- .../DownloadedBooksImportService.cs | 28 ++--- 6 files changed, 167 insertions(+), 36 deletions(-) diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs index e4c3764d21..879c184c32 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs @@ -115,7 +115,7 @@ private void GivenAuthorMatch() public void should_not_mark_as_imported_if_all_files_were_rejected() { Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( @@ -139,7 +139,7 @@ public void should_not_mark_as_imported_if_all_files_were_rejected() public void should_not_mark_as_imported_if_no_tracks_were_parsed() { Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( @@ -162,7 +162,7 @@ public void should_not_mark_as_imported_if_no_tracks_were_parsed() public void should_not_mark_as_failed_if_nothing_found_to_import() { Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List()); Subject.Import(_trackedDownload); @@ -174,7 +174,7 @@ public void should_not_mark_as_failed_if_nothing_found_to_import() public void should_not_mark_as_imported_if_all_files_were_skipped() { Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() }), "Test Failure"), @@ -197,7 +197,7 @@ public void should_mark_as_imported_if_all_tracks_were_imported_but_extra_files_ }; Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author })), @@ -224,7 +224,7 @@ public void should_not_mark_as_imported_if_some_tracks_were_not_imported() }; Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })), @@ -258,7 +258,7 @@ public void should_not_mark_as_imported_if_some_of_episodes_were_not_imported_in _trackedDownload.RemoteBook.Books = books; Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv" })), @@ -291,7 +291,7 @@ public void should_mark_as_imported_if_all_tracks_were_imported() }; Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( @@ -316,7 +316,7 @@ public void should_mark_as_imported_if_all_episodes_were_imported_including_hist _trackedDownload.RemoteBook.Books = books; Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( @@ -353,7 +353,7 @@ public void should_mark_as_imported_if_the_download_can_be_tracked_using_the_sou GivenABadlyNamedDownload(); Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author })) @@ -364,6 +364,100 @@ public void should_mark_as_imported_if_the_download_can_be_tracked_using_the_sou AssertImported(); } + [Test] + public void should_pass_book_override_when_remotebook_has_single_book() + { + var book = CreateBook(42); + _trackedDownload.RemoteBook.Books = new List { book }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author })) + }); + + Subject.Import(_trackedDownload); + + Mocker.GetMock() + .Verify(v => v.ProcessPath( + It.IsAny(), + ImportMode.Auto, + It.Is(o => o.Author == _trackedDownload.RemoteBook.Author && o.Book == book), + It.IsAny()), + Times.Once()); + } + + [Test] + public void should_not_pass_book_override_when_remotebook_has_multiple_books() + { + _trackedDownload.RemoteBook.Books = new List + { + CreateBook(1), + CreateBook(2), + CreateBook(3) + }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author })), + new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic(), Author = _author })), + new ImportResult(new ImportDecision(new LocalBook { Path = @"C:\TestPath\Droned.S01E03.mkv".AsOsAgnostic(), Author = _author })) + }); + + 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_pass_null_overrides_when_remotebook_is_null() + { + _trackedDownload.RemoteBook = 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 == null), + It.IsAny()), + Times.Once()); + } + + [Test] + public void should_pass_author_only_override_when_remotebook_has_empty_books() + { + _trackedDownload.RemoteBook.Books = new List(); + + 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()); + } + private void AssertNotImported() { Mocker.GetMock() @@ -375,7 +469,7 @@ private void AssertNotImported() private void AssertImported() { Mocker.GetMock() - .Verify(v => v.ProcessPath(_trackedDownload.DownloadItem.OutputPath.FullPath, ImportMode.Auto, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem), Times.Once()); + .Verify(v => v.ProcessPath(_trackedDownload.DownloadItem.OutputPath.FullPath, ImportMode.Auto, It.IsAny(), _trackedDownload.DownloadItem), Times.Once()); Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedAlbumsCommandServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedAlbumsCommandServiceFixture.cs index e17498278e..caeefe8f54 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedAlbumsCommandServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedAlbumsCommandServiceFixture.cs @@ -33,7 +33,7 @@ public void Setup() .Returns(new List()); Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List()); var downloadItem = Builder.CreateNew() @@ -106,7 +106,7 @@ public void should_process_folder_with_downloadclientitem_if_available() Subject.Execute(new DownloadedBooksScanCommand() { Path = _downloadFolder, DownloadClientId = "sab1" }); - Mocker.GetMock().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem), Times.Once()); + Mocker.GetMock().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, It.Is(o => o != null && o.Author == _trackedDownload.RemoteBook.Author), _trackedDownload.DownloadItem), Times.Once()); } [Test] diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs index 48cc96cc3c..98c5eda6cd 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs @@ -302,7 +302,7 @@ public void should_not_delete_folder_after_import() _trackedDownload.DownloadItem.CanMoveFiles = false; - Subject.ProcessPath(_droneFactory, ImportMode.Auto, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem); + Subject.ProcessPath(_droneFactory, ImportMode.Auto, new IdentificationOverrides { Author = _trackedDownload.RemoteBook.Author }, _trackedDownload.DownloadItem); DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue(); } @@ -316,7 +316,7 @@ public void should_delete_folder_if_importmode_move() _trackedDownload.DownloadItem.CanMoveFiles = false; - Subject.ProcessPath(_droneFactory, ImportMode.Move, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem); + Subject.ProcessPath(_droneFactory, ImportMode.Move, new IdentificationOverrides { Author = _trackedDownload.RemoteBook.Author }, _trackedDownload.DownloadItem); DiskProvider.FolderExists(_subFolders[0]).Should().BeFalse(); } @@ -330,7 +330,7 @@ public void should_not_delete_folder_if_importmode_copy() _trackedDownload.DownloadItem.CanMoveFiles = true; - Subject.ProcessPath(_droneFactory, ImportMode.Copy, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem); + Subject.ProcessPath(_droneFactory, ImportMode.Copy, new IdentificationOverrides { Author = _trackedDownload.RemoteBook.Author }, _trackedDownload.DownloadItem); DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue(); } diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index aced8bf7ab..4b87ca1344 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -88,7 +88,8 @@ public void Import(TrackedDownload trackedDownload) trackedDownload.State = TrackedDownloadState.Importing; var outputPath = trackedDownload.ImportItem.OutputPath.FullPath; - var importResults = _downloadedTracksImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteBook?.Author, trackedDownload.DownloadItem); + var idOverrides = BuildIdentificationOverrides(trackedDownload.RemoteBook); + var importResults = _downloadedTracksImportService.ProcessPath(outputPath, ImportMode.Auto, idOverrides, trackedDownload.DownloadItem); if (importResults.Empty()) { @@ -190,6 +191,28 @@ private void SetImportItem(TrackedDownload trackedDownload) trackedDownload.ImportItem = _provideImportItemService.ProvideImportItem(trackedDownload.DownloadItem, trackedDownload.ImportItem); } + private IdentificationOverrides BuildIdentificationOverrides(Parser.Model.RemoteBook remoteBook) + { + if (remoteBook == null) + { + return null; + } + + var overrides = new IdentificationOverrides + { + 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. + if (remoteBook.Books != null && remoteBook.Books.Count == 1) + { + overrides.Book = remoteBook.Books.First(); + } + + return overrides; + } + private bool ValidatePath(TrackedDownload trackedDownload) { var downloadItemOutputPath = trackedDownload.ImportItem.OutputPath; diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs index bd955e538f..7cc1237763 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs @@ -9,6 +9,7 @@ using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.MediaFiles.BookImport; using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.MediaFiles @@ -53,7 +54,8 @@ private List ProcessPath(DownloadedBooksScanCommand message) { _logger.Debug("External directory scan request for known download {0}. [{1}]", message.DownloadClientId, message.Path); - var importResults = _downloadedTracksImportService.ProcessPath(message.Path, message.ImportMode, trackedDownload.RemoteBook.Author, trackedDownload.DownloadItem); + var idOverrides = BuildIdentificationOverrides(trackedDownload.RemoteBook); + var importResults = _downloadedTracksImportService.ProcessPath(message.Path, message.ImportMode, idOverrides, trackedDownload.DownloadItem); _completedDownloadService.VerifyImport(trackedDownload, importResults); @@ -66,6 +68,26 @@ private List ProcessPath(DownloadedBooksScanCommand message) return _downloadedTracksImportService.ProcessPath(message.Path, message.ImportMode); } + private IdentificationOverrides BuildIdentificationOverrides(RemoteBook remoteBook) + { + if (remoteBook == null) + { + return null; + } + + var overrides = new IdentificationOverrides + { + Author = remoteBook.Author + }; + + if (remoteBook.Books != null && remoteBook.Books.Count == 1) + { + overrides.Book = remoteBook.Books.First(); + } + + return overrides; + } + public void Execute(DownloadedBooksScanCommand message) { List importResults; diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs index 3f965841fe..58ab2d5bf5 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedBooksImportService.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.MediaFiles public interface IDownloadedBooksImportService { List ProcessRootFolder(IDirectoryInfo directoryInfo); - List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Author author = null, DownloadClientItem downloadClientItem = null); + List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, IdentificationOverrides idOverrides = null, DownloadClientItem downloadClientItem = null); bool ShouldDeleteFolder(IDirectoryInfo directoryInfo); } @@ -76,7 +76,7 @@ public List ProcessRootFolder(IDirectoryInfo directoryInfo) return results; } - public List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Author author = null, DownloadClientItem downloadClientItem = null) + public List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, IdentificationOverrides idOverrides = null, DownloadClientItem downloadClientItem = null) { _logger.Debug("Processing path: {0}", path); @@ -84,24 +84,24 @@ public List ProcessPath(string path, ImportMode importMode = Impor { var directoryInfo = _diskProvider.GetDirectoryInfo(path); - if (author == null) + if (idOverrides?.Author == null) { return ProcessFolder(directoryInfo, importMode, downloadClientItem); } - return ProcessFolder(directoryInfo, importMode, author, downloadClientItem); + return ProcessFolder(directoryInfo, importMode, idOverrides, downloadClientItem); } if (_diskProvider.FileExists(path)) { var fileInfo = _diskProvider.GetFileInfo(path); - if (author == null) + if (idOverrides?.Author == null) { return ProcessFile(fileInfo, importMode, downloadClientItem); } - return ProcessFile(fileInfo, importMode, author, downloadClientItem); + return ProcessFile(fileInfo, importMode, idOverrides, downloadClientItem); } LogInaccessiblePathError(path); @@ -158,10 +158,10 @@ private List ProcessFolder(IDirectoryInfo directoryInfo, ImportMod var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); var author = _parsingService.GetAuthor(cleanedUpName); - return ProcessFolder(directoryInfo, importMode, author, downloadClientItem); + return ProcessFolder(directoryInfo, importMode, new IdentificationOverrides { Author = author }, downloadClientItem); } - private List ProcessFolder(IDirectoryInfo directoryInfo, ImportMode importMode, Author author, DownloadClientItem downloadClientItem) + private List ProcessFolder(IDirectoryInfo directoryInfo, ImportMode importMode, IdentificationOverrides idOverrides, DownloadClientItem downloadClientItem) { if (_authorService.AuthorPathExists(directoryInfo.FullName)) { @@ -207,10 +207,6 @@ private List ProcessFolder(IDirectoryInfo directoryInfo, ImportMod } } - var idOverrides = new IdentificationOverrides - { - Author = author - }; var idInfo = new ImportDecisionMakerInfo { DownloadClientItem = downloadClientItem, @@ -266,10 +262,10 @@ private List ProcessFile(IFileInfo fileInfo, ImportMode importMode }; } - return ProcessFile(fileInfo, importMode, author, downloadClientItem); + return ProcessFile(fileInfo, importMode, new IdentificationOverrides { Author = author }, downloadClientItem); } - private List ProcessFile(IFileInfo fileInfo, ImportMode importMode, Author author, DownloadClientItem downloadClientItem) + private List ProcessFile(IFileInfo fileInfo, ImportMode importMode, IdentificationOverrides idOverrides, DownloadClientItem downloadClientItem) { if (Path.GetFileNameWithoutExtension(fileInfo.Name).StartsWith("._")) { @@ -292,10 +288,6 @@ private List ProcessFile(IFileInfo fileInfo, ImportMode importMode } } - var idOverrides = new IdentificationOverrides - { - Author = author - }; var idInfo = new ImportDecisionMakerInfo { DownloadClientItem = downloadClientItem From 10d8b5d7a11fc28417db8b74f9a16a8686152f15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 21:00:41 +0000 Subject: [PATCH 4/9] Add GitHub Actions workflow to publish Docker image for Unraid Add a dedicated workflow that builds this feature branch and publishes a Docker image to ghcr.io/edlaughton/bookshelf:import-match-threshold-ui for testing on Unraid. Triggers on push to this branch and manual dispatch. Uses the repo's existing build.sh and docker/Dockerfile. Targets linux/amd64 only. https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .github/workflows/publish-unraid-image.yml | 78 ++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/publish-unraid-image.yml diff --git a/.github/workflows/publish-unraid-image.yml b/.github/workflows/publish-unraid-image.yml new file mode 100644 index 0000000000..9e1963ba37 --- /dev/null +++ b/.github/workflows/publish-unraid-image.yml @@ -0,0 +1,78 @@ +# Publishes a Docker image for the import-match-threshold-setting branch +# to GHCR for testing on Unraid. This workflow is separate from the main +# CI pipeline and only targets this feature branch. + +name: Publish Unraid Image + +permissions: + contents: read + packages: write + +on: + workflow_dispatch: + push: + branches: + - claude/import-match-threshold-setting-GefjL + +env: + DOTNET_VERSION: "6.0.428" + READARR_VERSION: 0.4.20 + IMAGE_TAG: ghcr.io/edlaughton/bookshelf:import-match-threshold-ui + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: jdx/mise-action@v3 + + - name: Setup environment variables + run: | + echo "DATE=$(date --rfc-3339=date)" >> "$GITHUB_ENV" + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: _cache/nuget + key: nuget-${{ runner.os }}-${{ hashFiles('src/Directory.Packages.props', 'src/**/*.csproj', 'global.json') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Cache Node modules + uses: actions/cache@v4 + with: + path: | + _cache/node + node_modules + key: node-${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock') }} + restore-keys: | + node-${{ runner.os }}- + + - name: Build backend and frontend + run: ./build.sh --backend --frontend + env: + READARRVERSION: ${{ env.READARR_VERSION }}.${{ github.run_number }} + BUILD_SOURCEBRANCHNAME: ${{ github.ref_name }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ env.IMAGE_TAG }} + build-args: | + GIT_BRANCH=${{ github.ref_name }} + COMMIT_HASH=${{ github.sha }} + BUILD_DATE=${{ env.DATE }} From 12ee553dda10b21b5d6bbdec5390caae2a2b9780 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 21:32:31 +0000 Subject: [PATCH 5/9] Enable Hardcover metadata in Unraid publish workflow Pass METADATA_URL and HARDCOVER build-args to match the main build workflow's Hardcover configuration. https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .github/workflows/publish-unraid-image.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-unraid-image.yml b/.github/workflows/publish-unraid-image.yml index 9e1963ba37..b1ed9687cf 100644 --- a/.github/workflows/publish-unraid-image.yml +++ b/.github/workflows/publish-unraid-image.yml @@ -76,3 +76,5 @@ jobs: GIT_BRANCH=${{ github.ref_name }} COMMIT_HASH=${{ github.sha }} BUILD_DATE=${{ env.DATE }} + METADATA_URL=https://hardcover.bookinfo.pro + HARDCOVER=true From f6f35f7cb08f57a54c021b178cd77a6f95b0cadb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 22:31:01 +0000 Subject: [PATCH 6/9] Update publish workflow to trigger on develop branch Both feature branches are being merged into develop, so the workflow trigger should point there instead of the individual feature branch. https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .github/workflows/publish-unraid-image.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-unraid-image.yml b/.github/workflows/publish-unraid-image.yml index b1ed9687cf..daf5a989c6 100644 --- a/.github/workflows/publish-unraid-image.yml +++ b/.github/workflows/publish-unraid-image.yml @@ -1,6 +1,5 @@ -# Publishes a Docker image for the import-match-threshold-setting branch -# to GHCR for testing on Unraid. This workflow is separate from the main -# CI pipeline and only targets this feature branch. +# Publishes a Docker image to GHCR for testing on Unraid. +# This workflow is separate from the main CI pipeline. name: Publish Unraid Image @@ -12,7 +11,7 @@ on: workflow_dispatch: push: branches: - - claude/import-match-threshold-setting-GefjL + - develop env: DOTNET_VERSION: "6.0.428" From a60166a3bf0b4ee395afce41f3a574acceff6486 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 22:44:27 +0000 Subject: [PATCH 7/9] Fix SA1210: order using directives alphabetically in DownloadedBooksCommandService https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs index 7cc1237763..79a40bfb29 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs @@ -9,8 +9,8 @@ using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.MediaFiles.BookImport; using NzbDrone.Core.MediaFiles.Commands; -using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles { From 7b6e5f641d382761189d566e0aba512a329354aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 22:56:25 +0000 Subject: [PATCH 8/9] Fix floating-point precision in book match threshold calculation Use (100 - percent) / 100.0 instead of 1.0 - (percent / 100.0) to avoid IEEE 754 imprecision where 1.0 - 0.9 = 0.09999999999999998 instead of 0.1. https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .../BookImport/Specifications/CloseAlbumMatchSpecification.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs index 38395dc9ea..d3aaeda9f4 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs @@ -20,7 +20,7 @@ public CloseBookMatchSpecification(IConfigService configService, Logger logger) public Decision IsSatisfiedBy(LocalEdition item, DownloadClientItem downloadClientItem) { - var bookThreshold = 1.0 - (_configService.BookImportMinimumMatchPercent / 100.0); + var bookThreshold = (100 - _configService.BookImportMinimumMatchPercent) / 100.0; double dist; string reasons; From 96e52678f559eabe19108464de0c83b9c1d057f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 23:09:15 +0000 Subject: [PATCH 9/9] Fix flaky boundary test for book match threshold Avoid testing exact floating-point boundary (0.1 == 0.1) since 0.3 / 3.0 produces 0.10000000000000002 due to IEEE 754 imprecision. Use 0.09 (clearly below threshold) instead. https://claude.ai/code/session_01976eMqb4mwazHh13NZkDY5 --- .../Specifications/CloseBookMatchSpecificationFixture.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs index 2713e38f33..0a4567c9e1 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs @@ -121,13 +121,13 @@ public void should_be_looser_with_lower_percentage() } [Test] - public void ninety_percent_produces_threshold_of_point_one() + public void ninety_percent_accepts_distance_just_below_threshold() { // 90% minimum match should produce internal threshold of 0.10 GivenMinimumMatchPercent(90); - // distance of 0.10 equals threshold of 0.10, should accept (not >) - GivenDistance("book", 0.10); + // distance of 0.09 is below threshold of 0.10, should accept + GivenDistance("book", 0.09); Subject.IsSatisfiedBy(_localEdition, null).Accepted.Should().BeTrue(); }