From f9a93076d9822dbc71eb1289198f663c90075033 Mon Sep 17 00:00:00 2001 From: dny238 Date: Tue, 28 Jul 2026 07:54:30 -0600 Subject: [PATCH 1/2] Match scanned files when the on-disk name drops "The" or author credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-audiobook scan (ScanFileDiscovery) only linked a file when the filename contained the full library title or the path contained the full author. Files whose folder/name dropped a leading article ("Language of Emotions" vs "The Language of Emotions") or whose author folder dropped post-nominal credentials/initials ("Karla McLaren" vs "M.Ed. Karla McLaren", "John Gottman" vs "John M. Gottman PhD") were never matched, so the file sat on disk unlinked and Scan reported nothing. Adds tolerant, token-based matching on top of the existing exact checks (purely additive — prior matches are unchanged): titles are compared with leading articles/subtitles normalized away, authors with honorifics and single-letter initials dropped, using order-independent token-subset comparison. Scoped to the audiobook's own scan folder, so leniency is safe. Adds ScanFileDiscoveryMatchTests covering the article/credential/subtitle cases plus a negative (unrelated file) and the exact-match baseline. Co-Authored-By: Claude Fable 5 --- .../Library/Scanning/ScanFileDiscovery.cs | 99 ++++++++++++++++++- .../Scanning/ScanFileDiscoveryMatchTests.cs | 70 +++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs diff --git a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs index 7ce228c6f..2b9281ba3 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs @@ -103,20 +103,111 @@ 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 token-based checks. These let a file still match when the on-disk name dropped a + // leading article or subtitle from the title (e.g. "Language of Emotions" for "The Language + // of Emotions") or honorifics/initials from the author (e.g. "Karla McLaren" for + // "M.Ed. Karla McLaren", or "John Gottman" for "John M. Gottman PhD"). + var titleTokens = TokenSet(NormalizeTitle(titleToken)); + if (titleTokens.Count > 0) + { + if (IsSubsetEitherWay(titleTokens, TokenSet(NormalizeTitle(fileStem)))) + { + return true; + } + + if (!string.IsNullOrEmpty(directoryName) + && IsSubsetEitherWay(titleTokens, TokenSet(NormalizeTitle(directoryName)))) + { + return true; + } + } + + var authorTokens = TokenSet(NormalizeAuthor(authorToken)); + if (authorTokens.Count > 0 && authorTokens.IsSubsetOf(TokenSet(NormalizeText(file)))) + { + return true; + } + + return false; } + + // Honorifics / post-nominal credentials that appear in author names but rarely on disk. + private static readonly HashSet AuthorNoiseTokens = new(StringComparer.Ordinal) + { + "phd", "ph", "md", "med", "ed", "dphil", "mph", "msw", "lcsw", "cfp", "mba", "bsc", "msc", + "dr", "jr", "sr", "ii", "iii", "rn" + }; + + 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 string NormalizeAuthor(string value) + { + var kept = NormalizeText(value) + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + // Drop single-letter initials and known honorifics/credentials. + .Where(token => token.Length > 1 && !AuthorNoiseTokens.Contains(token)); + return string.Join(' ', kept); + } + + private static HashSet TokenSet(string normalized) => + new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal); + + private static bool IsSubsetEitherWay(HashSet a, HashSet b) => + a.Count > 0 && b.Count > 0 && (a.IsSubsetOf(b) || b.IsSubsetOf(a)); } diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs new file mode 100644 index 000000000..af8bafadf --- /dev/null +++ b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs @@ -0,0 +1,70 @@ +/* + * 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_TitleHasSubtitle_FolderIsBaseTitle_StillMatches() + { + var file = @"E:\Audiobooks\Daniel J. Siegel\Developing Mind\Developing Mind.m4b"; + Assert.True(ScanFileDiscovery.Matches(file, "Developing Mind", "The Developing Mind, Third Edition", "Daniel J. Siegel M.D.")); + } + + [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")); + } + } +} From 0ac2326d77f9052b62937a36090c50830fbdb4cd Mon Sep 17 00:00:00 2001 From: dny238 Date: Thu, 30 Jul 2026 08:50:33 -0600 Subject: [PATCH 2/2] Restrict tolerant matching to title only; drop author-based fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (@m4bard, #784) measured that the author arm caused misattributions: NormalizeAuthor dropped single-letter initials so "M. R. James" collapsed to {james} and subset-matched against the whole path, linking unrelated files that merely shared an author's shelf (Henry James' "The Turn of the Screw" attributed to M. R. James' "Ghost Stories of an Antiquary"). Remove the author fallback entirely and tighten the title rule to strict token-set equality after article/punctuation normalization. This keeps the case this PR targets — on-disk names that only drop a leading "The" (and is what carries article-dropped names once #717 removes the pre-existing author arm) — without attributing files on title alone-less grounds. Adds a regression test for the cross-book/shared-surname case. Co-Authored-By: Claude Fable 5 --- .../Library/Scanning/ScanFileDiscovery.cs | 52 ++++--------------- .../Scanning/ScanFileDiscoveryMatchTests.cs | 14 ++--- 2 files changed, 19 insertions(+), 47 deletions(-) diff --git a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs index 2b9281ba3..0723ade45 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs @@ -124,41 +124,23 @@ internal static bool Matches( return true; } - // Tolerant token-based checks. These let a file still match when the on-disk name dropped a - // leading article or subtitle from the title (e.g. "Language of Emotions" for "The Language - // of Emotions") or honorifics/initials from the author (e.g. "Karla McLaren" for - // "M.Ed. Karla McLaren", or "John Gottman" for "John M. Gottman PhD"). + // 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) + if (titleTokens.Count == 0) { - if (IsSubsetEitherWay(titleTokens, TokenSet(NormalizeTitle(fileStem)))) - { - return true; - } - - if (!string.IsNullOrEmpty(directoryName) - && IsSubsetEitherWay(titleTokens, TokenSet(NormalizeTitle(directoryName)))) - { - return true; - } + return false; } - var authorTokens = TokenSet(NormalizeAuthor(authorToken)); - if (authorTokens.Count > 0 && authorTokens.IsSubsetOf(TokenSet(NormalizeText(file)))) - { - return true; - } - - return false; + return titleTokens.SetEquals(TokenSet(NormalizeTitle(fileStem))) + || (!string.IsNullOrEmpty(directoryName) + && titleTokens.SetEquals(TokenSet(NormalizeTitle(directoryName)))); } - // Honorifics / post-nominal credentials that appear in author names but rarely on disk. - private static readonly HashSet AuthorNoiseTokens = new(StringComparer.Ordinal) - { - "phd", "ph", "md", "med", "ed", "dphil", "mph", "msw", "lcsw", "cfp", "mba", "bsc", "msc", - "dr", "jr", "sr", "ii", "iii", "rn" - }; - private static string NormalizeText(string value) { if (string.IsNullOrEmpty(value)) @@ -196,18 +178,6 @@ private static string NormalizeTitle(string value) return text; } - private static string NormalizeAuthor(string value) - { - var kept = NormalizeText(value) - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - // Drop single-letter initials and known honorifics/credentials. - .Where(token => token.Length > 1 && !AuthorNoiseTokens.Contains(token)); - return string.Join(' ', kept); - } - private static HashSet TokenSet(string normalized) => new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal); - - private static bool IsSubsetEitherWay(HashSet a, HashSet b) => - a.Count > 0 && b.Count > 0 && (a.IsSubsetOf(b) || b.IsSubsetOf(a)); } diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs index af8bafadf..8369a3035 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanFileDiscoveryMatchTests.cs @@ -54,17 +54,19 @@ public void Matches_AuthorWithMiddleInitial_StillMatches() } [Fact] - public void Matches_TitleHasSubtitle_FolderIsBaseTitle_StillMatches() + public void Matches_UnrelatedFile_DoesNotMatch() { - var file = @"E:\Audiobooks\Daniel J. Siegel\Developing Mind\Developing Mind.m4b"; - Assert.True(ScanFileDiscovery.Matches(file, "Developing Mind", "The Developing Mind, Third Edition", "Daniel J. Siegel M.D.")); + 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_UnrelatedFile_DoesNotMatch() + public void Matches_DifferentBookSharingAnAuthorSurname_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")); + // 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")); } } }