aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPiotr Niełacny <piotr.nielacny@gmail.com>2026-03-10 16:53:12 +0100
committerPiotr Niełacny <piotr.nielacny@gmail.com>2026-07-29 10:41:41 +0200
commit9693f11fe5507a005fc7fcd0e30ef918ab1f214d (patch)
treead3eba15cc221d3e9d0fc531b4e82e2fc6b40244
parentafb716566d04102fa6c8e1b12977d175db301081 (diff)
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
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs15
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs17
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs11
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs10
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/UserViewBuilderFilterTests.cs107
5 files changed, 147 insertions, 13 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index a4de9feb05..3fc51bf332 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -197,20 +197,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;
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 209feac702..bdc236ffdb 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -982,7 +982,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)
{
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index b1f7f29bad..70f877b421 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -1100,17 +1100,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;
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index 9ba103cc8b..2ccf898c1b 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -514,18 +514,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;
diff --git a/tests/Jellyfin.Controller.Tests/Entities/UserViewBuilderFilterTests.cs b/tests/Jellyfin.Controller.Tests/Entities/UserViewBuilderFilterTests.cs
new file mode 100644
index 0000000000..b6701f769c
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/UserViewBuilderFilterTests.cs
@@ -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);
+ }
+}