Fix series merging

This commit is contained in:
Shadowghost
2026-07-23 13:59:42 +02:00
parent 88216e0ec4
commit d4cddb8a5d
2 changed files with 135 additions and 13 deletions
@@ -0,0 +1,98 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
/// </summary>
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="libraryManager">The library manager.</param>
public RecomputeSeriesPresentationKey(
IStartupLogger<RecomputeSeriesPresentationKey> logger,
ILibraryManager libraryManager)
{
_logger = logger;
_libraryManager = libraryManager;
}
/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series }
}).OfType<Series>().ToArray();
_logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length);
const int ProgressInterval = 500;
var sw = Stopwatch.StartNew();
var processed = 0;
var updated = 0;
foreach (var item in series)
{
cancellationToken.ThrowIfCancellationRequested();
if (++processed % ProgressInterval == 0)
{
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
}
var oldKey = item.PresentationUniqueKey;
var newKey = item.CreatePresentationUniqueKey();
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
{
continue;
}
item.PresentationUniqueKey = newKey;
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
// to the series by it. Look them up by the old key (they still carry it) and re-point
// them at the new key so they stay attached without waiting for the next scan.
if (!string.IsNullOrEmpty(oldKey))
{
var children = _libraryManager.GetItemList(new InternalItemsQuery
{
SeriesPresentationUniqueKey = oldKey,
IncludeItemTypes = [BaseItemKind.Season, BaseItemKind.Episode]
});
foreach (var child in children)
{
if (child is IHasSeries hasSeries
&& !string.Equals(hasSeries.SeriesPresentationUniqueKey, newKey, StringComparison.Ordinal))
{
hasSeries.SeriesPresentationUniqueKey = newKey;
await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
}
}
updated++;
}
_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
}
}
+37 -13
View File
@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@@ -82,16 +81,23 @@ namespace MediaBrowser.Controller.Entities.TV
{
var userdatakeys = GetUserDataKeys();
if (userdatakeys.Count > 1)
// The first user data key is a stable cross-folder identity.
// When none exists, fall back to the (normalized) series name.
var groupingKey = userdatakeys.Count > 1
? userdatakeys[0]
: GetNameBasedGroupingKey();
if (!string.IsNullOrEmpty(groupingKey))
{
return AddLibrariesToPresentationUniqueKey(userdatakeys[0]);
return AppendPreferredLanguage(groupingKey);
}
}
return base.CreatePresentationUniqueKey();
}
private string AddLibrariesToPresentationUniqueKey(string key)
// The owning libraries are deliberately NOT part of the key.
private string AppendPreferredLanguage(string key)
{
var lang = GetPreferredMetadataLanguage();
if (!string.IsNullOrEmpty(lang))
@@ -99,16 +105,15 @@ namespace MediaBrowser.Controller.Entities.TV
key += "-" + lang;
}
var folders = LibraryManager.GetCollectionFolders(this)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.ToArray();
return key;
}
if (folders.Length == 0)
{
return key;
}
return key + "-" + string.Join('-', folders);
private string GetNameBasedGroupingKey()
{
// Prefix with the type so a series can never collide with a same-named item of another kind.
return string.IsNullOrEmpty(Name)
? null
: "series-" + Name.ToLowerInvariant();
}
private static string GetUniqueSeriesKey(BaseItem series)
@@ -188,6 +193,25 @@ namespace MediaBrowser.Controller.Entities.TV
return list;
}
/// <inheritdoc />
protected override Guid[] GetExtraOwnerIds()
{
if (!LibraryManager.GetLibraryOptions(this).EnableAutomaticSeriesGrouping)
{
return base.GetExtraOwnerIds();
}
// Setting PresentationUniqueKey on the query disables presentation-key grouping, so this
// returns every folder-item of the merged series rather than the collapsed survivor.
var ids = LibraryManager.GetItemIds(new InternalItemsQuery
{
PresentationUniqueKey = GetPresentationUniqueKey(),
IncludeItemTypes = [BaseItemKind.Series]
});
return ids.Count == 0 ? base.GetExtraOwnerIds() : ids.ToArray();
}
public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
{
return GetSeasons(user, new DtoOptions(true));