diff options
| -rw-r--r-- | Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs | 98 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/TV/Series.cs | 50 |
2 files changed, 135 insertions, 13 deletions
diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs new file mode 100644 index 0000000000..3542c580a8 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs @@ -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); + } +} diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 952187c6e1..9cd53cac92 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -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(); - - if (folders.Length == 0) - { - return key; - } + 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)); |
