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, }; } }