diff options
| author | Piotr Niełacny <piotr.nielacny@gmail.com> | 2026-03-10 16:53:12 +0100 |
|---|---|---|
| committer | Piotr Niełacny <piotr.nielacny@gmail.com> | 2026-07-29 10:41:41 +0200 |
| commit | 9693f11fe5507a005fc7fcd0e30ef918ab1f214d (patch) | |
| tree | ad3eba15cc221d3e9d0fc531b4e82e2fc6b40244 | |
| parent | afb716566d04102fa6c8e1b12977d175db301081 (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
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); + } +} |
