From cfb0b4c6caf04b9bc81e148e3c2eedfff43018c7 Mon Sep 17 00:00:00 2001 From: dny238 Date: Mon, 21 Sep 2026 15:34:51 -0600 Subject: [PATCH] Tolerate a differing leading article in book-boundary title matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto canary 1.3.4 (the previous head was based on 1.3.2, before #717 rewrote the scanner, so CI failed testing the merge into current canary). A folder whose title drops or adds a leading article ("The"/"A"/"An") — e.g. "Language of Emotions" for "The Language of Emotions", or "The Myth of Normal" for "Myth of Normal" — now matches the expected title. This stays full-title equality modulo the article: it never widens into substring or author matching, so the same-author / different-book boundary guards remain intact (covered by a test that two "The …" siblings under one author are not cross-linked). Co-Authored-By: Claude Opus 4.8 --- .../Scanning/ScanFileDiscovery.Metadata.cs | 30 ++++++++++ .../Scanning/ScanFileDiscoveryTests.cs | 55 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs index 39d70adfa..d1380504a 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Metadata.cs @@ -145,6 +145,21 @@ private static bool SegmentMatchesExpectedTitle( return true; } + // Tolerate a differing leading article ("The"/"A"/"An") on either side, so a + // folder named "Language of Emotions" still matches the expected title + // "The Language of Emotions" (and vice versa). This stays a full-title equality + // modulo the article -- it never widens into substring or author matching, so + // the same-author / different-book boundary guards remain intact. + var articleFreeSegment = StripLeadingArticle(normalizedSegment); + if (titleTokens.Any(token => + string.Equals( + StripLeadingArticle(token), + articleFreeSegment, + StringComparison.Ordinal))) + { + return true; + } + var components = segment .Split( [" - ", " – ", " — "], @@ -167,6 +182,21 @@ private static bool SegmentMatchesExpectedTitle( return false; } + private static readonly string[] LeadingArticlePrefixes = ["the ", "a ", "an "]; + + private static string StripLeadingArticle(string normalizedToken) + { + foreach (var prefix in LeadingArticlePrefixes) + { + if (normalizedToken.StartsWith(prefix, StringComparison.Ordinal)) + { + return normalizedToken[prefix.Length..]; + } + } + + return normalizedToken; + } + private static string? TryFindIdentifierBoundary( string candidate, string canonicalRoot, diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs index 83b43ae27..313d94d24 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryTests.cs @@ -314,6 +314,61 @@ public void Discover_LinkedDirectoryInsideIdentifierBoundary_IsNotTraversed() && issue.Path == link); } + [Fact] + public void FindMatchingAudioFiles_FolderDropsLeadingThe_StillMatches() + { + var requested = CreateAudioFile( + "Karla McLaren", + "Language of Emotions", + "Language of Emotions.m4b"); + var audiobook = new AudiobookBuilder() + .WithTitle("The Language of Emotions") + .WithAuthor("Karla McLaren") + .Build(); + + var result = Discover(audiobook); + + var found = Assert.Single(result); + Assert.Equal(requested, found); + } + + [Fact] + public void FindMatchingAudioFiles_FolderAddsLeadingArticle_StillMatches() + { + var requested = CreateAudioFile( + "Gabor Mate", + "The Myth of Normal", + "The Myth of Normal.m4b"); + var audiobook = new AudiobookBuilder() + .WithTitle("Myth of Normal") + .WithAuthor("Gabor Mate") + .Build(); + + var result = Discover(audiobook); + + var found = Assert.Single(result); + Assert.Equal(requested, found); + } + + [Fact] + public void FindMatchingAudioFiles_ArticleToleranceDoesNotCrossLinkSiblingBooks() + { + // Same author, two books that both start with "The". Article-insensitivity + // must still compare the full remaining title, so only the requested book + // is attributed -- it must not collapse "The Reckoning" onto "The Awakening". + var requested = CreateAudioFile("Shared Author", "The Reckoning", "The Reckoning.m4b"); + _ = CreateAudioFile("Shared Author", "The Awakening", "The Awakening.m4b"); + var audiobook = new AudiobookBuilder() + .WithTitle("The Reckoning") + .WithAuthor("Shared Author") + .Build(); + + var result = Discover(audiobook); + + var found = Assert.Single(result); + Assert.Equal(requested, found); + } + private List Discover(Audiobook audiobook) => DiscoverResult(audiobook).AttributedFiles.ToList();