Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,81 @@ private static List<string> 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<string> TokenSet(string normalized) =>
new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal);
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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"));
}
}
}