diff --git a/.github/workflows/publish-unraid-image.yml b/.github/workflows/publish-unraid-image.yml
new file mode 100644
index 0000000000..daf5a989c6
--- /dev/null
+++ b/.github/workflows/publish-unraid-image.yml
@@ -0,0 +1,79 @@
+# Publishes a Docker image to GHCR for testing on Unraid.
+# This workflow is separate from the main CI pipeline.
+
+name: Publish Unraid Image
+
+permissions:
+ contents: read
+ packages: write
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - develop
+
+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 }}
+ METADATA_URL=https://hardcover.bookinfo.pro
+ HARDCOVER=true
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 ?
()
- .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.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs
new file mode 100644
index 0000000000..0a4567c9e1
--- /dev/null
+++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/CloseBookMatchSpecificationFixture.cs
@@ -0,0 +1,170 @@
+using FluentAssertions;
+using NUnit.Framework;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.MediaFiles.BookImport.Identification;
+using NzbDrone.Core.MediaFiles.BookImport.Specifications;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Test.Framework;
+
+namespace NzbDrone.Core.Test.MediaFiles.BookImport.Specifications
+{
+ [TestFixture]
+ public class CloseBookMatchSpecificationFixture : CoreTest
+ {
+ 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_accepts_distance_just_below_threshold()
+ {
+ // 90% minimum match should produce internal threshold of 0.10
+ GivenMinimumMatchPercent(90);
+
+ // distance of 0.09 is below threshold of 0.10, should accept
+ GivenDistance("book", 0.09);
+
+ 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/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/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..d3aaeda9f4 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 = (100 - _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/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs
index bd955e538f..79a40bfb29 100644
--- a/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs
+++ b/src/NzbDrone.Core/MediaFiles/DownloadedBooksCommandService.cs
@@ -10,6 +10,7 @@
using NzbDrone.Core.MediaFiles.BookImport;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Parser.Model;
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
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,
};
}
}