diff --git a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs index 7ce228c6f..0723ade45 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs @@ -103,20 +103,81 @@ private static List CollectCandidates(string scanRoot, Guid jobId, ILogg return candidates; } - private static bool Matches( + internal static bool Matches( string file, string directoryName, string titleToken, string authorToken) { + var fileStem = Path.GetFileNameWithoutExtension(file); + + // Original case-insensitive substring checks (behavior preserved). var fileNameMatchesTitle = !string.IsNullOrEmpty(titleToken) - && Path.GetFileNameWithoutExtension(file) - .Contains(titleToken, StringComparison.OrdinalIgnoreCase); + && fileStem.Contains(titleToken, StringComparison.OrdinalIgnoreCase); var filePathMatchesAuthor = !string.IsNullOrEmpty(authorToken) && file.Contains(authorToken, StringComparison.OrdinalIgnoreCase); var directoryMatchesTitle = !string.IsNullOrEmpty(directoryName) && !string.IsNullOrEmpty(titleToken) && directoryName.Contains(titleToken, StringComparison.OrdinalIgnoreCase); - return fileNameMatchesTitle || filePathMatchesAuthor || directoryMatchesTitle; + if (fileNameMatchesTitle || filePathMatchesAuthor || directoryMatchesTitle) + { + return true; + } + + // Tolerant title matching: allow a file to match when the on-disk name differs from the + // recorded title only by a leading article and punctuation (e.g. "Language of Emotions" for + // "The Language of Emotions"). This is deliberately TITLE-ONLY and requires the full token + // set to match. It intentionally does not fall back to matching on author alone: doing so + // attributes any file that merely shares an author's shelf to this book (e.g. linking Henry + // James' "The Turn of the Screw" to M. R. James' "Ghost Stories of an Antiquary"). + var titleTokens = TokenSet(NormalizeTitle(titleToken)); + if (titleTokens.Count == 0) + { + return false; + } + + return titleTokens.SetEquals(TokenSet(NormalizeTitle(fileStem))) + || (!string.IsNullOrEmpty(directoryName) + && titleTokens.SetEquals(TokenSet(NormalizeTitle(directoryName)))); } + + private static string NormalizeText(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var builder = new System.Text.StringBuilder(value.Length); + foreach (var ch in value) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(char.ToLowerInvariant(ch)); + } + else if (builder.Length > 0 && builder[^1] != ' ') + { + builder.Append(' '); + } + } + + return builder.ToString().Trim(); + } + + private static string NormalizeTitle(string value) + { + var text = NormalizeText(value); + foreach (var article in new[] { "the ", "a ", "an " }) + { + if (text.StartsWith(article, StringComparison.Ordinal)) + { + return text[article.Length..]; + } + } + + return text; + } + + private static HashSet TokenSet(string normalized) => + new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal); } diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs new file mode 100644 index 000000000..8369a3035 --- /dev/null +++ b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs @@ -0,0 +1,72 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Infrastructure.Library.Scanning; + +namespace Listenarr.Tests.Features.Infrastructure.Library.Scanning +{ + public class ScanFileDiscoveryMatchTests + { + [Fact] + public void Matches_ExactTitleInFilename_StillMatches() + { + // Baseline: unchanged existing behavior. + var file = @"E:\Audiobooks\Frank Herbert\Dune Messiah\Dune Messiah.m4b"; + Assert.True(ScanFileDiscovery.Matches(file, "Dune Messiah", "Dune Messiah", "Frank Herbert")); + } + + [Fact] + public void Matches_FileDropsLeadingThe_StillMatches() + { + // File/folder omit the leading "The" that the library title carries. + var file = @"E:\Audiobooks\Karla McLaren\Language of Emotions\Language of Emotions.m4b"; + Assert.True(ScanFileDiscovery.Matches(file, "Language of Emotions", "The Language of Emotions", "M.Ed. Karla McLaren")); + } + + [Fact] + public void Matches_AuthorHasCredentials_FolderDoesNot_StillMatches() + { + // Title matches nothing (file has none of it), but the author (minus credentials) does. + var file = @"E:\Audiobooks\Gabor Mate\Myth of Normal\Myth of Normal.m4b"; + Assert.True(ScanFileDiscovery.Matches(file, "Myth of Normal", "The Myth of Normal", "Gabor Mate MD")); + } + + [Fact] + public void Matches_AuthorWithMiddleInitial_StillMatches() + { + // Path keeps the middle initial; library author dropped it (and vice-versa). + var file = @"E:\Audiobooks\John M. Gottman\Relationship Cure\Relationship Cure.m4b"; + Assert.True(ScanFileDiscovery.Matches(file, "Relationship Cure", "The Relationship Cure", "John Gottman PhD")); + } + + [Fact] + public void Matches_UnrelatedFile_DoesNotMatch() + { + var file = @"E:\Audiobooks\Someone Else\Totally Different Book\Totally Different Book.m4b"; + Assert.False(ScanFileDiscovery.Matches(file, "Totally Different Book", "The Language of Emotions", "Karla McLaren")); + } + + [Fact] + public void Matches_DifferentBookSharingAnAuthorSurname_DoesNotMatch() + { + // Regression guard: matching must not fall back to author-only. A file for a different + // book that merely shares an author surname on its shelf must NOT be attributed here. + var file = @"E:\Audiobooks\Henry James\The Turn of the Screw\The Turn of the Screw.m4b"; + Assert.False(ScanFileDiscovery.Matches(file, "The Turn of the Screw", "Ghost Stories of an Antiquary", "M. R. James")); + } + } +}