Fix NameStartsWith filter not matching numeric item names

The NameStartsWith, NameStartsWithOrGreater, and NameLessThan API
filters compare user input against the internal SortName, which
zero-pads numbers to 10 digits for natural sorting. This caused
numeric filters like NameStartsWith=1 to fail because "1" doesn't
match the padded sort name "0000000012...".

Normalize filter input through ModifySortChunks before comparison so
that padding is applied consistently. For NameStartsWith, add a
TrimStart('0') fallback so that numeric prefixes like "1" correctly
match sort names such as "0000000012 years a slave".

Add NormalizeSortNameFilter helper on BaseItem and make
ModifySortChunks public. Add unit tests covering numeric, alphabetic,
and mixed prefix matching for all three filter types.

Fixes #1470
This commit is contained in:
Piotr Niełacny 2026-03-10 16:53:12 +01:00
parent 622947e374
commit 856d8a3b7d
5 changed files with 153 additions and 13 deletions

View file

@ -194,20 +194,23 @@ public sealed partial class BaseItemRepository
{
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
{
var nameStartsWithLower = filter.NameStartsWith.ToLowerInvariant();
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().StartsWith(nameStartsWithLower));
var paddedPrefix = BaseItem.NormalizeSortNameFilter(filter.NameStartsWith);
var rawPrefix = filter.NameStartsWith.ToLowerInvariant();
dbQuery = dbQuery.Where(e =>
e.SortName!.ToLower().StartsWith(paddedPrefix)
|| e.SortName!.ToLower().TrimStart('0').StartsWith(rawPrefix));
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{
var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(startsOrGreaterLower) >= 0);
var paddedValue = BaseItem.NormalizeSortNameFilter(filter.NameStartsWithOrGreater);
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(paddedValue) >= 0);
}
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{
var lessThanLower = filter.NameLessThan.ToLowerInvariant();
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(lessThanLower) < 0);
var paddedValue = BaseItem.NormalizeSortNameFilter(filter.NameLessThan);
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(paddedValue) < 0);
}
return dbQuery;

View file

@ -959,7 +959,22 @@ namespace MediaBrowser.Controller.Entities
return ModifySortChunks(sortable);
}
internal static string ModifySortChunks(ReadOnlySpan<char> name)
/// <summary>
/// Normalizes a user-provided name filter value to match against <see cref="SortName"/>.
/// Applies the same zero-padding, diacritics removal, and transliteration as sort name generation.
/// </summary>
/// <param name="input">The raw filter value from the API.</param>
/// <returns>The normalized value suitable for comparison against SortName.</returns>
public static string NormalizeSortNameFilter(string input)
=> ModifySortChunks(input).ToLowerInvariant();
/// <summary>
/// Processes a name by zero-padding numeric chunks to 10 digits for natural sort order,
/// removing diacritics, and transliterating non-ASCII characters.
/// </summary>
/// <param name="name">The input name to process.</param>
/// <returns>The processed string with padded numeric chunks.</returns>
public static string ModifySortChunks(ReadOnlySpan<char> name)
{
static void AppendChunk(StringBuilder builder, bool isDigitChunk, ReadOnlySpan<char> chunk)
{

View file

@ -1073,6 +1073,12 @@ namespace MediaBrowser.Controller.Entities
items = ApplyNameFilter(items, query);
}
// This must be the last filter
if (!query.AdjacentTo.IsNullOrEmpty())
{
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
}
var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
@ -1088,17 +1094,22 @@ namespace MediaBrowser.Controller.Entities
{
if (!string.IsNullOrWhiteSpace(query.NameStartsWith))
{
items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase));
var paddedPrefix = BaseItem.NormalizeSortNameFilter(query.NameStartsWith);
items = items.Where(i =>
i.SortName.StartsWith(paddedPrefix, StringComparison.OrdinalIgnoreCase)
|| i.SortName.TrimStart('0').StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater))
{
items = items.Where(i => string.Compare(i.SortName, query.NameStartsWithOrGreater, StringComparison.OrdinalIgnoreCase) >= 0);
var paddedValue = BaseItem.NormalizeSortNameFilter(query.NameStartsWithOrGreater);
items = items.Where(i => string.Compare(i.SortName, paddedValue, StringComparison.OrdinalIgnoreCase) >= 0);
}
if (!string.IsNullOrWhiteSpace(query.NameLessThan))
{
items = items.Where(i => string.Compare(i.SortName, query.NameLessThan, StringComparison.OrdinalIgnoreCase) < 0);
var paddedValue = BaseItem.NormalizeSortNameFilter(query.NameLessThan);
items = items.Where(i => string.Compare(i.SortName, paddedValue, StringComparison.OrdinalIgnoreCase) < 0);
}
return items;

View file

@ -500,18 +500,22 @@ namespace MediaBrowser.Controller.Entities
IUserDataManager userDataManager,
ILibraryManager libraryManager)
{
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
if (!string.IsNullOrEmpty(query.NameStartsWith)
&& !item.SortName.StartsWith(BaseItem.NormalizeSortNameFilter(query.NameStartsWith), StringComparison.InvariantCultureIgnoreCase)
&& !item.SortName.TrimStart('0').StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
{
return false;
}
#pragma warning disable CA1309 // Use ordinal string comparison
if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater) && string.Compare(query.NameStartsWithOrGreater, item.SortName, StringComparison.InvariantCultureIgnoreCase) == 1)
if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater)
&& string.Compare(BaseItem.NormalizeSortNameFilter(query.NameStartsWithOrGreater), item.SortName, StringComparison.InvariantCultureIgnoreCase) == 1)
{
return false;
}
if (!string.IsNullOrEmpty(query.NameLessThan) && string.Compare(query.NameLessThan, item.SortName, StringComparison.InvariantCultureIgnoreCase) != 1)
if (!string.IsNullOrEmpty(query.NameLessThan)
&& string.Compare(BaseItem.NormalizeSortNameFilter(query.NameLessThan), item.SortName, StringComparison.InvariantCultureIgnoreCase) != 1)
#pragma warning restore CA1309 // Use ordinal string comparison
{
return false;

View file

@ -0,0 +1,107 @@
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests.Entities;
public class UserViewBuilderFilterTests
{
public UserViewBuilderFilterTests()
{
var configManager = new Mock<IServerConfigurationManager>();
configManager.Setup(m => m.Configuration).Returns(new ServerConfiguration());
BaseItem.ConfigurationManager = configManager.Object;
}
private static Video CreateVideo(string name) => new Video { Name = name };
[Theory]
// Numeric names: NameStartsWith should match after stripping zero-padding
[InlineData("12 Years a Slave", "1", true)]
[InlineData("12 Years a Slave", "12", true)]
[InlineData("12 Years a Slave", "2", false)]
[InlineData("300", "3", true)]
[InlineData("300", "30", true)]
[InlineData("300", "0", false)]
// Alphabetic names: standard prefix match on SortName
[InlineData("Avatar", "A", true)]
[InlineData("Avatar", "a", true)]
[InlineData("Avatar", "B", false)]
[InlineData("The Matrix", "m", true)]
[InlineData("The Matrix", "t", false)]
// Mixed alpha-numeric input: handled via ModifySortChunks padding
[InlineData("Apollo 13", "apollo 13", true)]
[InlineData("Apollo 13", "apollo 2", false)]
// Edge case: "0" should only match items whose name is literally "0"
[InlineData("0", "0", true)]
[InlineData("1", "0", false)]
public void Filter_NameStartsWith_MatchesNumericAndAlphabeticPrefixes(string name, string nameStartsWith, bool expected)
{
var item = CreateVideo(name);
var query = new InternalItemsQuery
{
NameStartsWith = nameStartsWith
};
var result = UserViewBuilder.Filter(new[] { (BaseItem)item }, null!, query, null!, null!).Any();
Assert.Equal(expected, result);
}
[Fact]
public void Filter_NameStartsWith_NullOrEmpty_PassesThrough()
{
var items = new[] { (BaseItem)CreateVideo("Avatar") };
var queryNull = new InternalItemsQuery { NameStartsWith = null };
var queryEmpty = new InternalItemsQuery { NameStartsWith = string.Empty };
Assert.True(UserViewBuilder.Filter(items, null!, queryNull, null!, null!).Any());
Assert.True(UserViewBuilder.Filter(items, null!, queryEmpty, null!, null!).Any());
}
[Theory]
// Input is plain user value — padding is applied internally
[InlineData("12 Years a Slave", "12", true)]
[InlineData("12 Years a Slave", "13", false)]
[InlineData("Avatar", "a", true)]
[InlineData("Avatar", "avatar", true)]
[InlineData("Avatar", "b", false)]
public void Filter_NameStartsWithOrGreater_PadsInputBeforeComparing(string name, string nameStartsWithOrGreater, bool expected)
{
var item = CreateVideo(name);
var query = new InternalItemsQuery
{
NameStartsWithOrGreater = nameStartsWithOrGreater
};
var result = UserViewBuilder.Filter(new[] { (BaseItem)item }, null!, query, null!, null!).Any();
Assert.Equal(expected, result);
}
[Theory]
// Input is plain user value — padding is applied internally
[InlineData("12 Years a Slave", "13", true)]
[InlineData("12 Years a Slave", "12", false)]
[InlineData("Avatar", "b", true)]
[InlineData("Avatar", "a", false)]
public void Filter_NameLessThan_PadsInputBeforeComparing(string name, string nameLessThan, bool expected)
{
var item = CreateVideo(name);
var query = new InternalItemsQuery
{
NameLessThan = nameLessThan
};
var result = UserViewBuilder.Filter(new[] { (BaseItem)item }, null!, query, null!, null!).Any();
Assert.Equal(expected, result);
}
}