From d6018230b03e6fb1ff5bab66cfdf42bf511f41e3 Mon Sep 17 00:00:00 2001 From: Seb Clark Date: Thu, 27 Aug 2026 13:35:52 +0100 Subject: [PATCH] Fix: V5-suggested authors already in the library are not linked when author import is disallowed --- .../AuthorRestrictedV5RecoveryFixture.cs | 83 +++++++++++++++++++ .../BookImport/FileMatchingService.cs | 50 ++++++++++- 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/Chaptarr.Core.Test/MediaFiles/BookImport/AuthorRestrictedV5RecoveryFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/BookImport/AuthorRestrictedV5RecoveryFixture.cs index 410a7197..b5e490bf 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/BookImport/AuthorRestrictedV5RecoveryFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/BookImport/AuthorRestrictedV5RecoveryFixture.cs @@ -677,5 +677,88 @@ private static object InvokeV5SuggestionWithPathFallback( questionsWithoutSuggestion ?? new HashSet(StringComparer.Ordinal) }); } + + [Test] + public async Task author_restricted_mode_should_link_v5_suggested_author_that_already_exists_when_import_disallowed() + { + var logger = LogManager.GetCurrentClassLogger(); + var containment = new ContainmentValidator(new TagNormalizer(), logger); + var v5 = new RecordingV5MatchingService + { + OnSearch = (_, tags, _, _) => + { + var artistValues = tags.TryGetValue("ARTIST", out var artist) ? artist : new List(); + if (artistValues.Any(v => string.Equals(v, "Frank Herbert", StringComparison.OrdinalIgnoreCase))) + { + return new List + { + new V5MatchedAuthor + { + id = "hc:frank-herbert", + name = "Frank Herbert", + edition_hardcover_id = "hc-ed-675" + } + }; + } + + return new List(); + } + }; + var fts = new RecordingEditionFtsRepository(); + var authorService = new StubAuthorService( + (new Author { Id = 31, Name = "Brian Herbert", Path = "/audiobooks/Brian Herbert" }, "hc", "brian-herbert"), + (new Author { Id = 6, Name = "Frank Herbert", Path = "/audiobooks/Frank Herbert" }, "hc", "frank-herbert")); + + var svc = new FileMatchingService( + matchingLogger: new NullMatchingUploadLogger(), + v5MatchingService: v5, + containmentValidator: containment, + pendingAuthorImportService: null, + commandQueue: null, + authorFolderMatchingService: null, + rootFolderService: null, + configService: ConfigServiceTestProxy.Create(strictness: BookMatchingStrictness.Balanced, usePathAsTagsFallback: true), + authorService: authorService, + eventAggregator: null, + authorLibraryService: null, + editionFtsRepository: fts, + bookService: null, + editionService: null, + editionRepository: null, + mediaInfoExtractor: null, + logger: logger); + + var file = new DiscoveredFileWithMetadata + { + Path = "/audiobooks/Brian Herbert/Whipping Star/Whipping Star.m4b", + DurationSeconds = 36010, + AllTags = new Dictionary> + { + { "TITLE", new List { "Whipping Star" } }, + { "ARTIST", new List { "Frank Herbert" } } + } + }; + + // Downloaded-import style context: V5 identification on, author + // import off. The suggested author already exists in the library, + // so the file must still link to it. + var context = new MatchingContext + { + AllowV5Identification = true, + AllowAuthorImport = false, + DeferUnmatchedToAuthorReady = false, + AllowUnscopedFallback = false, + DisablePathFallback = true, + PerFileMatching = false + }; + + var result = await svc.MatchFilesToLibraryAsync(new[] { file }, restrictToAuthorId: 31, context); + + Assert.That(result.UnmatchedFiles, Is.Empty); + Assert.That(result.MatchedFiles, Has.Length.EqualTo(1)); + Assert.That(result.MatchedFiles[0].AuthorId, Is.EqualTo(6)); + Assert.That(result.MatchedFiles[0].BookTitle, Is.EqualTo("Whipping Star")); + } + } } diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/FileMatchingService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/FileMatchingService.cs index 20d3defe..d4bb1a11 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/FileMatchingService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/FileMatchingService.cs @@ -3146,7 +3146,31 @@ private bool TryResolveLocalV5WorkBoundary( if (!allowAuthorImport) { - return (null, suggestion, s.Reason); + // Import is off-limits here, but linking to an author the + // library already has is not an import. Without this lookup + // every V5 suggestion whose author entered the library under + // a different provider id dies as "no match in local library" + // even though the author (and often the book) is present. + var existingOnly = TryFindExistingSuggestedAuthor(s.ProviderId); + if (existingOnly == null || existingOnly.Id <= 0) + { + return (null, suggestion, s.Reason); + } + + var scopedExistingMatch = EvaluateHolyGrailMatchFileInternal( + file, + mediaType, + existingOnly.Id, + disablePathFallback: true, + inferAuthorFromPathDuringPathFallback: true, + unscoped: false, + hardAllowedBookIds: hardAllowedBookIds)?.Match; + if (scopedExistingMatch != null) + { + return (scopedExistingMatch, suggestion, $"Recovered via existing V5 author '{s.AuthorName}'"); + } + + return (null, suggestion, $"NO_EDITION_FOUND (existing authorId={existingOnly.Id})"); } var recoveredAuthor = TryGetOrImportSuggestedAuthorForRestrictedRecovery(s.ProviderId, s.AuthorName, file.Path, mediaType); @@ -3171,6 +3195,30 @@ private bool TryResolveLocalV5WorkBoundary( return (null, suggestion, $"NO_EDITION_FOUND (authorId={recoveredAuthor.Id})"); } + private Author TryFindExistingSuggestedAuthor(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return null; + } + + var colon = providerId.IndexOf(':'); + if (colon <= 0 || colon >= providerId.Length - 1) + { + return null; + } + + try + { + return _authorService?.FindByProviderId(providerId.Substring(0, colon), providerId.Substring(colon + 1)); + } + catch (Exception ex) + { + _logger.Debug(ex, "[FALLBACK-V5] Existing-author lookup failed for '{0}'", providerId); + return null; + } + } + private Author TryGetOrImportSuggestedAuthorForRestrictedRecovery(string providerId, string authorName, string samplePath, BookMediaType mediaType) { if (string.IsNullOrWhiteSpace(providerId))