diff options
| author | Cody Robibero <cody@robibe.ro> | 2026-07-26 16:19:11 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-26 16:19:11 -0400 |
| commit | 02d8e7d8288028763aa23b5a2c67b3d66a0f652c (patch) | |
| tree | 553fc40ad73af32115fc501c9662e0f2cb3203f5 | |
| parent | 1e4d126cb9f1590cfdc0e731647b93836d1a8867 (diff) | |
| parent | 95330223f49c6ba8b1d77fbe4e4dad4fc6ba9be9 (diff) | |
Merge pull request #17417 from Shadowghost/series-merge-fixes
Fix series merging
| -rw-r--r-- | Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs | 102 | ||||
| -rw-r--r-- | MediaBrowser.Controller/Entities/TV/Series.cs | 50 |
2 files changed, 139 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..60bb3fd1db --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs @@ -0,0 +1,102 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +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; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <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> + /// <param name="dbProvider">The database context factory.</param> + public RecomputeSeriesPresentationKey( + IStartupLogger<RecomputeSeriesPresentationKey> logger, + ILibraryManager libraryManager, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series] + }).OfType<Series>().ToArray(); + + _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); + + const int ProgressInterval = 250; + var sw = Stopwatch.StartNew(); + var processed = 0; + var updated = 0; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + 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; + } + + // Write only the changed column instead of re-persisting the whole item. + var id = item.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + // Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched + // to the series by it. Re-point every child still carrying the old key in a single set-based + // update so they stay attached without waiting for the next scan. + if (!string.IsNullOrEmpty(oldKey)) + { + await dbContext.BaseItems + .Where(e => e.SeriesPresentationUniqueKey == oldKey) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), 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 d9f300ad20..3ce241aca8 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)); |
