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();