< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.RepairAlternateVersionLinks
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs
Line coverage
92%
Covered lines: 89
Uncovered lines: 7
Coverable lines: 96
Total lines: 216
Line coverage: 92.7%
Branch coverage
88%
Covered branches: 32
Total branches: 36
Branch coverage: 88.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/29/2026 - 12:17:11 AM Line coverage: 92.7% (89/96) Branch coverage: 88.8% (32/36) Total lines: 216 10/29/2026 - 12:17:11 AM Line coverage: 92.7% (89/96) Branch coverage: 88.8% (32/36) Total lines: 216

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
PerformAsync()80%212088.88%
ResolvePrimaries(...)100%1616100%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using Jellyfin.Database.Implementations;
 8using Jellyfin.Server.ServerSetupApp;
 9using Microsoft.EntityFrameworkCore;
 10using Microsoft.Extensions.Logging;
 11using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
 12
 13namespace Jellyfin.Server.Migrations.Routines;
 14
 15/// <summary>
 16/// Re-points every video that is linked as an alternate version at the primary it belongs to.
 17/// </summary>
 18[JellyfinMigration("2026-09-08T12:00:00", nameof(RepairAlternateVersionLinks))]
 19[JellyfinMigrationBackup(JellyfinDb = true)]
 20internal class RepairAlternateVersionLinks : IAsyncMigrationRoutine
 21{
 22    private const int BatchSize = 1000;
 23
 24    private readonly IStartupLogger<RepairAlternateVersionLinks> _logger;
 25    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 26
 27    /// <summary>
 28    /// Initializes a new instance of the <see cref="RepairAlternateVersionLinks"/> class.
 29    /// </summary>
 30    /// <param name="logger">The startup logger.</param>
 31    /// <param name="dbProvider">The database context factory.</param>
 32    public RepairAlternateVersionLinks(
 33        IStartupLogger<RepairAlternateVersionLinks> logger,
 34        IDbContextFactory<JellyfinDbContext> dbProvider)
 35    {
 536        _logger = logger;
 537        _dbProvider = dbProvider;
 538    }
 39
 40    /// <inheritdoc />
 41    public async Task PerformAsync(CancellationToken cancellationToken)
 42    {
 543        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 544        await using (dbContext.ConfigureAwait(false))
 45        {
 546            var links = await dbContext.LinkedChildren
 547                .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion
 548                    || lc.ChildType == LinkedChildType.LinkedAlternateVersion)
 549                .Select(lc => new { lc.ParentId, lc.ChildId, lc.ChildType })
 550                .ToListAsync(cancellationToken)
 551                .ConfigureAwait(false);
 52
 553            if (links.Count == 0)
 54            {
 055                _logger.LogInformation("No alternate version links found, nothing to repair.");
 056                return;
 57            }
 58
 59            // A version belongs to one primary; a file-based link outranks a user-merged one, as it
 60            // does everywhere else these two link types meet.
 561            var primaryByChild = links
 562                .GroupBy(l => l.ChildId)
 563                .ToDictionary(
 564                    g => g.Key,
 565                    g => g.OrderBy(l => l.ChildType == LinkedChildType.LocalAlternateVersion ? 0 : 1)
 566                        .First()
 567                        .ParentId);
 68
 69            // A version that is its own primary would be hidden from every list by the repair below.
 1270            foreach (var selfLink in primaryByChild.Where(kvp => kvp.Value.Equals(kvp.Key)).ToList())
 71            {
 172                _logger.LogWarning("Skipping alternate version {ChildId}, which is linked to itself.", selfLink.Key);
 173                primaryByChild.Remove(selfLink.Key);
 74            }
 75
 576            ResolvePrimaries(primaryByChild);
 77
 578            var repaired = 0;
 579            var promoted = 0;
 80
 81            // The primaries are loaded along with their versions: a primary that carries a
 82            // PrimaryVersionId of its own hides the whole group it heads.
 583            var itemIds = primaryByChild.Keys.Concat(primaryByChild.Values).Distinct().ToList();
 1884            for (var offset = 0; offset < itemIds.Count; offset += BatchSize)
 85            {
 486                cancellationToken.ThrowIfCancellationRequested();
 87
 488                var batch = itemIds.GetRange(offset, Math.Min(BatchSize, itemIds.Count - offset));
 489                var items = await dbContext.BaseItems
 490                    .Where(e => batch.Contains(e.Id))
 491                    .ToListAsync(cancellationToken)
 492                    .ConfigureAwait(false);
 93
 2694                foreach (var item in items)
 95                {
 996                    if (primaryByChild.TryGetValue(item.Id, out var primaryId))
 97                    {
 98                        // Mirrors Video.CreatePresentationUniqueKey for a video that has a primary.
 599                        var expectedKey = primaryId.ToString("N", CultureInfo.InvariantCulture);
 5100                        if (item.PrimaryVersionId.HasValue
 5101                            && primaryId.Equals(item.PrimaryVersionId.Value)
 5102                            && string.Equals(item.PresentationUniqueKey, expectedKey, StringComparison.Ordinal))
 103                        {
 104                            continue;
 105                        }
 106
 5107                        item.PrimaryVersionId = primaryId;
 5108                        item.PresentationUniqueKey = expectedKey;
 5109                        repaired++;
 110                    }
 4111                    else if (item.PrimaryVersionId.HasValue)
 112                    {
 1113                        if (item.OwnerId.HasValue)
 114                        {
 115                            // An owned item is hidden by its owner rather than by its primary, so
 116                            // clearing the primary here would not bring the group back.
 0117                            _logger.LogWarning(
 0118                                "Alternate versions are linked to {ItemId}, which is owned by {OwnerId}; the group stays
 0119                                item.Id,
 0120                                item.OwnerId.Value);
 0121                            continue;
 122                        }
 123
 124                        // Nothing links this one as a version, so the leftover primary is stale and
 125                        // would hide it, and with it every version linked to it.
 1126                        _logger.LogWarning(
 1127                            "Clearing the stale primary {PrimaryVersionId} of {ItemId}, which other versions are linked 
 1128                            item.PrimaryVersionId.Value,
 1129                            item.Id);
 130
 1131                        item.PrimaryVersionId = null;
 1132                        item.PresentationUniqueKey = item.Id.ToString("N", CultureInfo.InvariantCulture);
 1133                        promoted++;
 134                    }
 135                }
 136
 4137                await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 138            }
 139
 5140            _logger.LogInformation(
 5141                "Repaired {Repaired} of {Total} alternate version links, and promoted {Promoted} primaries that were ver
 5142                repaired,
 5143                primaryByChild.Count,
 5144                promoted);
 5145        }
 5146    }
 147
 148    private void ResolvePrimaries(Dictionary<Guid, Guid> primaryByChild)
 149    {
 5150        var resolvedPrimaries = new Dictionary<Guid, Guid>(primaryByChild.Count);
 151
 22152        foreach (var start in primaryByChild.Keys.ToList())
 153        {
 6154            if (resolvedPrimaries.ContainsKey(start))
 155            {
 156                continue;
 157            }
 158
 5159            var chain = new List<Guid>();
 5160            var walked = new HashSet<Guid>();
 5161            var current = start;
 162            Guid primary;
 163
 6164            while (true)
 165            {
 11166                if (resolvedPrimaries.TryGetValue(current, out var resolved))
 167                {
 1168                    primary = resolved;
 1169                    break;
 170                }
 171
 10172                if (!primaryByChild.TryGetValue(current, out var next))
 173                {
 174                    // Nothing is linking this one as a version of something else, so it heads the group.
 3175                    primary = current;
 3176                    break;
 177                }
 178
 7179                if (!walked.Add(current))
 180                {
 1181                    var loop = chain.Skip(chain.IndexOf(current)).ToList();
 182
 183                    // Which member heads the group is arbitrary; the lowest id keeps the repair
 184                    // stable if the migration is ever re-run over the same data.
 1185                    primary = loop.Min();
 1186                    _logger.LogWarning(
 1187                        "Alternate version links form a loop ({Loop}); keeping {PrimaryId} as the primary of the group."
 1188                        string.Join(" -> ", loop),
 1189                        primary);
 190
 1191                    primaryByChild.Remove(primary);
 1192                    break;
 193                }
 194
 6195                chain.Add(current);
 6196                current = next;
 197            }
 198
 22199            foreach (var version in chain)
 200            {
 6201                resolvedPrimaries[version] = primary;
 202            }
 203        }
 204
 22205        foreach (var (version, primary) in resolvedPrimaries)
 206        {
 6207            if (primary.Equals(version))
 208            {
 209                // The member of a loop that was kept as the primary of its group.
 210                continue;
 211            }
 212
 5213            primaryByChild[version] = primary;
 214        }
 5215    }
 216}