aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-07-23 13:59:42 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-07-23 14:03:13 +0200
commitd4cddb8a5de9d3eec07f5e5f594d21d787655269 (patch)
treeda57c0947ccb30468ba0db9a00ffb9a09dce6e7c
parent88216e0ec4adc388a1db795f18fa8f4591ebc099 (diff)
Fix series merging
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs98
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs50
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));