mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-11-07 19:46:15 +03:00
Merge pull request #16383 from LTe/fix/name-starts-with-numeric-filter
Fix NameStartsWith filter not matching numeric item names
This commit is contained in:
@@ -227,20 +227,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).ToLowerInvariant();
|
||||
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).ToLowerInvariant();
|
||||
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).ToLowerInvariant();
|
||||
dbQuery = dbQuery.Where(e => e.SortName!.ToLower().CompareTo(paddedValue) < 0);
|
||||
}
|
||||
|
||||
return dbQuery;
|
||||
|
||||
@@ -1045,20 +1045,41 @@ namespace MediaBrowser.Controller.Entities
|
||||
}
|
||||
}
|
||||
|
||||
return ModifySortChunks(ApplySortCharacterRules(sortable, configuration));
|
||||
}
|
||||
|
||||
private static string ApplySortCharacterRules(string name, ServerConfiguration configuration)
|
||||
{
|
||||
foreach (var removeChar in configuration.SortRemoveCharacters)
|
||||
{
|
||||
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
|
||||
name = name.Replace(removeChar, string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
foreach (var replaceChar in configuration.SortReplaceCharacters)
|
||||
{
|
||||
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
|
||||
name = name.Replace(replaceChar, " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return ModifySortChunks(sortable);
|
||||
return name;
|
||||
}
|
||||
|
||||
internal static string ModifySortChunks(ReadOnlySpan<char> name)
|
||||
/// <summary>
|
||||
/// Normalizes a user-provided name filter value to match against <see cref="SortName"/>.
|
||||
/// Applies the same character removal/replacement, zero-padding, diacritics removal, and transliteration
|
||||
/// as sort name generation, but not <c>SortRemoveWords</c> since stripping articles from a prefix is unsafe.
|
||||
/// </summary>
|
||||
/// <param name="input">The raw filter value from the API.</param>
|
||||
/// <returns>The normalized value suitable for comparison against SortName. Casing is preserved; callers requiring case-sensitive matching must handle it themselves.</returns>
|
||||
public static string NormalizeSortNameFilter(string input)
|
||||
=> ModifySortChunks(ApplySortCharacterRules(input, ConfigurationManager.Configuration));
|
||||
|
||||
/// <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)
|
||||
{
|
||||
|
||||
@@ -1182,17 +1182,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;
|
||||
|
||||
@@ -531,18 +531,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;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 genuinely starts with "0"
|
||||
[InlineData("0", "0", true)]
|
||||
[InlineData("1", "0", false)]
|
||||
[InlineData("0.0MHz", "0", true)]
|
||||
[InlineData("0.0MHz", "0.0", true)]
|
||||
[InlineData("0.0MHz", "1", false)]
|
||||
// Punctuation in the filter goes through the same replace/remove rules as sort names
|
||||
[InlineData("Mr. Bean", "Mr. B", true)]
|
||||
[InlineData("Spider-Man", "Spider-M", true)]
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user