diff options
| author | Cody Robibero <cody@robibe.ro> | 2026-07-24 21:34:37 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-24 21:34:37 -0400 |
| commit | 42e52f60ad99908500a966a2af97816a44fec7db (patch) | |
| tree | e34dce4e3ace249879c8898c5028a5838e32654d | |
| parent | 6ac64c531939e70d90903af60c59dbee4d2463e8 (diff) | |
| parent | 929e1936eb4be924c15f8724ac4a988b4cf25f0c (diff) | |
Merge pull request #17402 from Shadowghost/clean-forced-sort-name
Apply cleaning logic on ForcedSortName
3 files changed, 166 insertions, 9 deletions
diff --git a/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs new file mode 100644 index 0000000000..e9eefc20dc --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs @@ -0,0 +1,113 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to recompute the SortName of all items that have a forced sort name. +/// </summary> +[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class RefreshForcedSortNames : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RefreshForcedSortNames> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerConfigurationManager _configurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> + /// <param name="configurationManager">The server configuration manager providing the sort rules.</param> + public RefreshForcedSortNames( + IStartupLogger<RefreshForcedSortNames> logger, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerConfigurationManager configurationManager) + { + _logger = logger; + _dbProvider = dbProvider; + _configurationManager = configurationManager; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var configuration = _configurationManager.Configuration; + // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules. + var personType = typeof(Person).ToString(); + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName)); + _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records); + + var processedInPartition = 0; + + await foreach (var item in context.BaseItems + .Where(b => !string.IsNullOrEmpty(b.ForcedSortName)) + .OrderBy(e => e.Id) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal); + var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration); + if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'", + item.Id, + item.SortName, + newSortName); + item.SortName = newSortName; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}", + itemCount, + records, + sw.Elapsed); + } +} diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 49a4ed4bf6..21a726aaec 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -540,8 +541,8 @@ namespace MediaBrowser.Controller.Entities { if (!string.IsNullOrEmpty(ForcedSortName)) { - // Need the ToLower because that's what CreateSortName does - _sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant(); + // Run the forced sort name through the same cleaning as auto-generated sort names. + _sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration); } else { @@ -926,19 +927,31 @@ namespace MediaBrowser.Controller.Entities /// <returns>System.String.</returns> protected virtual string CreateSortName() { - if (Name is null) + return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration); + } + + /// <summary> + /// Cleans a raw name into its sortable form by applying the configured sort rules. + /// </summary> + /// <param name="name">The raw name to clean.</param> + /// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param> + /// <param name="configuration">The server configuration providing the sort rules.</param> + /// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns> + public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration) + { + if (name is null) { return null; // some items may not have name filled in properly } - if (!EnableAlphaNumericSorting) + if (!enableAlphaNumericSorting) { - return Name.TrimStart(); + return name.TrimStart(); } - var sortable = Name.Trim().ToLowerInvariant(); + var sortable = name.Trim().ToLowerInvariant(); - foreach (var search in ConfigurationManager.Configuration.SortRemoveWords) + foreach (var search in configuration.SortRemoveWords) { // Remove from beginning if a space follows if (sortable.StartsWith(search + " ", StringComparison.Ordinal)) @@ -956,12 +969,12 @@ namespace MediaBrowser.Controller.Entities } } - foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters) + foreach (var removeChar in configuration.SortRemoveCharacters) { sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal); } - foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters) + foreach (var replaceChar in configuration.SortReplaceCharacters) { sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal); } diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index c0a2b0ecca..de109c8d65 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -4,10 +4,12 @@ using System.Linq; using System.Reflection; using System.Threading; using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; @@ -29,6 +31,35 @@ public class BaseItemTests => Assert.Equal(expected, BaseItem.ModifySortChunks(input)); [Theory] + [InlineData("The Matrix", "matrix")] + [InlineData("Spider-Man", "spiderman")] + [InlineData("A Movie: Part 2", "movie: part 0000000002")] + public void GetSortName_AppliesConfiguredCleaning(string input, string expected) + => Assert.Equal(expected, BaseItem.GetSortName(input, true, new ServerConfiguration())); + + [Fact] + public void GetSortName_WithoutAlphaNumericSorting_ReturnsTrimmedInput() + => Assert.Equal("The Matrix", BaseItem.GetSortName(" The Matrix", false, new ServerConfiguration())); + + [Fact] + public void SortName_ForcedSortName_IsCleanedLikeAutoSortName() + { + var configManager = new Mock<IServerConfigurationManager>(); + configManager.Setup(x => x.Configuration).Returns(new ServerConfiguration()); + BaseItem.ConfigurationManager = configManager.Object; + + const string Raw = "The Spider-Man: Homecoming"; + + var auto = new Video { Name = Raw }; + var forced = new Video { Name = "zzz unrelated name", ForcedSortName = Raw }; + + // A forced sort name must be cleaned the same way as an auto-generated one so both sort together (#17388). + Assert.Equal(auto.SortName, forced.SortName); + // Sanity: cleaning actually ran (leading article and hyphen removed, colon kept, lowercased). + Assert.Equal("spiderman: homecoming", forced.SortName); + } + + [Theory] [InlineData("/Movies/Ted/Ted.mp4", "/Movies/Ted/Ted - Unrated Edition.mp4", "Ted", "Unrated Edition")] [InlineData("/Movies/Deadpool 2 (2018)/Deadpool 2 (2018).mkv", "/Movies/Deadpool 2 (2018)/Deadpool 2 (2018) - Super Duper Cut.mkv", "Deadpool 2 (2018)", "Super Duper Cut")] public void GetMediaSourceName_Valid(string primaryPath, string altPath, string name, string altName) |
