< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.RecomputeSeriesPresentationKey
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 60
Coverable lines: 60
Total lines: 151
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 14
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 7/27/2026 - 12:16:14 AM Line coverage: 0% (0/35) Branch coverage: 0% (0/8) Total lines: 10210/29/2026 - 12:17:11 AM Line coverage: 0% (0/60) Branch coverage: 0% (0/14) Total lines: 151 7/27/2026 - 12:16:14 AM Line coverage: 0% (0/35) Branch coverage: 0% (0/8) Total lines: 10210/29/2026 - 12:17:11 AM Line coverage: 0% (0/60) Branch coverage: 0% (0/14) Total lines: 151

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
PerformAsync()0%4260%
RecomputeSeasonsAsync()0%7280%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Diagnostics;
 4using System.Globalization;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Jellyfin.Data.Enums;
 9using Jellyfin.Database.Implementations;
 10using Jellyfin.Server.ServerSetupApp;
 11using MediaBrowser.Controller.Entities;
 12using MediaBrowser.Controller.Entities.TV;
 13using MediaBrowser.Controller.Library;
 14using Microsoft.EntityFrameworkCore;
 15using Microsoft.Extensions.Logging;
 16
 17namespace Jellyfin.Server.Migrations.Routines;
 18
 19/// <summary>
 20/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library.
 21/// </summary>
 22[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))]
 23[JellyfinMigrationBackup(JellyfinDb = true)]
 24internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
 25{
 26    private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger;
 27    private readonly ILibraryManager _libraryManager;
 28    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 29
 30    /// <summary>
 31    /// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class.
 32    /// </summary>
 33    /// <param name="logger">The startup logger.</param>
 34    /// <param name="libraryManager">The library manager.</param>
 35    /// <param name="dbProvider">The database context factory.</param>
 36    public RecomputeSeriesPresentationKey(
 37        IStartupLogger<RecomputeSeriesPresentationKey> logger,
 38        ILibraryManager libraryManager,
 39        IDbContextFactory<JellyfinDbContext> dbProvider)
 40    {
 041        _logger = logger;
 042        _libraryManager = libraryManager;
 043        _dbProvider = dbProvider;
 044    }
 45
 46    /// <inheritdoc />
 47    public async Task PerformAsync(CancellationToken cancellationToken)
 48    {
 049        var series = _libraryManager.GetItemList(new InternalItemsQuery
 050        {
 051            IncludeItemTypes = [BaseItemKind.Series]
 052        }).OfType<Series>().ToArray();
 53
 054        _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length);
 55
 56        const int ProgressInterval = 250;
 057        var sw = Stopwatch.StartNew();
 058        var newSeriesKeys = new Dictionary<Guid, string>();
 059        var processed = 0;
 060        var updated = 0;
 61
 062        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 063        await using (dbContext.ConfigureAwait(false))
 64        {
 065            foreach (var item in series)
 66            {
 067                cancellationToken.ThrowIfCancellationRequested();
 68
 069                if (++processed % ProgressInterval == 0)
 70                {
 071                    _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}"
 72                }
 73
 074                var newKey = item.CreatePresentationUniqueKey();
 075                newSeriesKeys[item.Id] = newKey;
 76
 077                if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal))
 78                {
 79                    continue;
 80                }
 81
 82                // Write only the changed column instead of re-persisting the whole item.
 083                var id = item.Id;
 084                await dbContext.BaseItems
 085                    .Where(e => e.Id.Equals(id))
 086                    .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
 087                    .ConfigureAwait(false);
 88
 89                // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so
 90                // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than
 91                // by the old key: that key can be shared by every library holding the series, so matching
 92                // on it would drag the other libraries' children along.
 093                await dbContext.BaseItems
 094                    .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id))
 095                    .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToke
 096                    .ConfigureAwait(false);
 97
 098                updated++;
 099            }
 100
 0101            var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait
 102
 0103            _logger.LogInformation(
 0104                "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Ela
 0105                updated,
 0106                series.Length,
 0107                updatedSeasons,
 0108                sw.Elapsed);
 109        }
 0110    }
 111
 112    private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, C
 113    {
 114        // A season's own key embeds its series' key, so it goes stale with it.
 0115        var seasons = _libraryManager.GetItemList(new InternalItemsQuery
 0116        {
 0117            IncludeItemTypes = [BaseItemKind.Season]
 0118        }).OfType<Season>().ToArray();
 119
 0120        var updated = 0;
 121
 0122        foreach (var season in seasons)
 123        {
 0124            cancellationToken.ThrowIfCancellationRequested();
 125
 126            // Without an index number the season keeps the base key, which carries no series key at all.
 0127            if (!season.IndexNumber.HasValue
 0128                || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey))
 129            {
 130                continue;
 131            }
 132
 133            // Mirrors Season.CreatePresentationUniqueKey.
 0134            var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture);
 0135            if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal))
 136            {
 137                continue;
 138            }
 139
 0140            var id = season.Id;
 0141            await dbContext.BaseItems
 0142                .Where(e => e.Id.Equals(id))
 0143                .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
 0144                .ConfigureAwait(false);
 145
 0146            updated++;
 147        }
 148
 0149        return updated;
 0150    }
 151}