From 8e80677bdd6471d04748cfcca41f997f2f48b341 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 21 Aug 2026 22:48:47 +0200 Subject: Fix series merging leaking across libraries and under-counting merged children --- ...0260723120000_RecomputeSeriesPresentationKey.cs | 102 -------------- ...0260821120000_RecomputeSeriesPresentationKey.cs | 151 +++++++++++++++++++++ 2 files changed, 151 insertions(+), 102 deletions(-) delete mode 100644 Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs (limited to 'Jellyfin.Server') diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs deleted file mode 100644 index 60bb3fd1db..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs +++ /dev/null @@ -1,102 +0,0 @@ -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; - -/// -/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format. -/// -[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))] -[JellyfinMigrationBackup(JellyfinDb = true)] -internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine -{ - private readonly IStartupLogger _logger; - private readonly ILibraryManager _libraryManager; - private readonly IDbContextFactory _dbProvider; - - /// - /// Initializes a new instance of the class. - /// - /// The startup logger. - /// The library manager. - /// The database context factory. - public RecomputeSeriesPresentationKey( - IStartupLogger logger, - ILibraryManager libraryManager, - IDbContextFactory dbProvider) - { - _logger = logger; - _libraryManager = libraryManager; - _dbProvider = dbProvider; - } - - /// - public async Task PerformAsync(CancellationToken cancellationToken) - { - var series = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Series] - }).OfType().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/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs new file mode 100644 index 0000000000..0e50ec2f47 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +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; + +/// +/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library. +/// +[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine +{ + private readonly IStartupLogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IDbContextFactory _dbProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The library manager. + /// The database context factory. + public RecomputeSeriesPresentationKey( + IStartupLogger logger, + ILibraryManager libraryManager, + IDbContextFactory dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _dbProvider = dbProvider; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series] + }).OfType().ToArray(); + + _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); + + const int ProgressInterval = 250; + var sw = Stopwatch.StartNew(); + var newSeriesKeys = new Dictionary(); + 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 newKey = item.CreatePresentationUniqueKey(); + newSeriesKeys[item.Id] = newKey; + + if (string.Equals(item.PresentationUniqueKey, 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 are matched to their series by SeriesPresentationUniqueKey, so + // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than + // by the old key: that key can be shared by every library holding the series, so matching + // on it would drag the other libraries' children along. + await dbContext.BaseItems + .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}", + updated, + series.Length, + updatedSeasons, + sw.Elapsed); + } + } + + private async Task RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary newSeriesKeys, CancellationToken cancellationToken) + { + // A season's own key embeds its series' key, so it goes stale with it. + var seasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season] + }).OfType().ToArray(); + + var updated = 0; + + foreach (var season in seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Without an index number the season keeps the base key, which carries no series key at all. + if (!season.IndexNumber.HasValue + || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey)) + { + continue; + } + + // Mirrors Season.CreatePresentationUniqueKey. + var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + var id = season.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + return updated; + } +} -- cgit v1.2.3