< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.MergeDuplicatePeople
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 183
Coverable lines: 183
Total lines: 312
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 36
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/11/2026 - 12:15:59 AM Line coverage: 0% (0/173) Branch coverage: 0% (0/30) Total lines: 2945/22/2026 - 12:15:17 AM Line coverage: 0% (0/177) Branch coverage: 0% (0/32) Total lines: 3006/28/2026 - 12:15:35 AM Line coverage: 0% (0/183) Branch coverage: 0% (0/36) Total lines: 312 5/11/2026 - 12:15:59 AM Line coverage: 0% (0/173) Branch coverage: 0% (0/30) Total lines: 2945/22/2026 - 12:15:17 AM Line coverage: 0% (0/177) Branch coverage: 0% (0/32) Total lines: 3006/28/2026 - 12:15:35 AM Line coverage: 0% (0/183) Branch coverage: 0% (0/36) Total lines: 312

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
PerformAsync()100%210%
MergePersonBaseItemsAsync()0%506220%
MergePeoplesRowsAsync()0%210140%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Linq;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Jellyfin.Database.Implementations;
 9using Jellyfin.Server.ServerSetupApp;
 10using MediaBrowser.Controller.Library;
 11using MediaBrowser.Controller.Persistence;
 12using Microsoft.EntityFrameworkCore;
 13using Microsoft.Extensions.Logging;
 14
 15namespace Jellyfin.Server.Migrations.Routines;
 16
 17/// <summary>
 18/// Merges case-only duplicate people. Two passes:
 19/// 1) Person BaseItems whose Name differs only by casing — Person.GetPath hashes the name
 20///    verbatim, so two casings produce two distinct Person rows in BaseItems.
 21/// 2) Peoples lookup rows whose Name differs only by casing within the same PersonType —
 22///    UpdatePeople used to insert a second Peoples row when a metadata provider returned
 23///    a different casing than the row already in the table.
 24/// Both bugs cause the /Persons endpoint to list the same person twice.
 25/// </summary>
 26[JellyfinMigration("2026-05-08T13:00:00", nameof(MergeDuplicatePeople))]
 27[JellyfinMigrationBackup(JellyfinDb = true)]
 28public class MergeDuplicatePeople : IAsyncMigrationRoutine
 29{
 30    private const string PersonType = "MediaBrowser.Controller.Entities.Person";
 31
 32    private readonly IStartupLogger<MergeDuplicatePeople> _logger;
 33    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
 34    private readonly ILibraryManager _libraryManager;
 35    private readonly IItemPersistenceService _persistenceService;
 36
 37    /// <summary>
 38    /// Initializes a new instance of the <see cref="MergeDuplicatePeople"/> class.
 39    /// </summary>
 40    /// <param name="logger">The startup logger.</param>
 41    /// <param name="dbContextFactory">The database context factory.</param>
 42    /// <param name="libraryManager">The library manager.</param>
 43    /// <param name="persistenceService">The item persistence service.</param>
 44    public MergeDuplicatePeople(
 45        IStartupLogger<MergeDuplicatePeople> logger,
 46        IDbContextFactory<JellyfinDbContext> dbContextFactory,
 47        ILibraryManager libraryManager,
 48        IItemPersistenceService persistenceService)
 49    {
 050        _logger = logger;
 051        _dbContextFactory = dbContextFactory;
 052        _libraryManager = libraryManager;
 053        _persistenceService = persistenceService;
 054    }
 55
 56    /// <inheritdoc/>
 57    public async Task PerformAsync(CancellationToken cancellationToken)
 58    {
 059        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 060        await using (context.ConfigureAwait(false))
 61        {
 062            await MergePersonBaseItemsAsync(context, cancellationToken).ConfigureAwait(false);
 063            await MergePeoplesRowsAsync(context, cancellationToken).ConfigureAwait(false);
 64        }
 065    }
 66
 67    private async Task MergePersonBaseItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
 68    {
 069        var persons = await context.BaseItems
 070            .Where(b => b.Type == PersonType && b.Name != null)
 071            .Select(b => new { b.Id, b.Name, b.DateCreated })
 072            .ToListAsync(cancellationToken)
 073            .ConfigureAwait(false);
 74
 075        var groups = persons
 076            .GroupBy(p => p.Name!.ToLowerInvariant())
 077            .Where(g => g.Count() > 1)
 078            .ToList();
 79
 080        if (groups.Count == 0)
 81        {
 082            _logger.LogInformation("No case-only duplicate Person BaseItems found.");
 083            return;
 84        }
 85
 086        _logger.LogInformation("Found {Count} groups of case-only duplicate Person BaseItems.", groups.Count);
 87
 088        var idsToDelete = new List<Guid>();
 089        foreach (var group in groups)
 90        {
 091            cancellationToken.ThrowIfCancellationRequested();
 92
 093            var groupIds = group.Select(g => g.Id).ToArray();
 94
 95            // Pick the keeper: the Person with the most UserData rows (favorites, image
 96            // refresh state) is the one users have actually interacted with.
 097            var stats = await context.BaseItems
 098                .Where(b => groupIds.Contains(b.Id))
 099                .Select(b => new
 0100                {
 0101                    b.Id,
 0102                    b.Name,
 0103                    b.DateCreated,
 0104                    UserDataCount = context.UserData.Count(u => u.ItemId == b.Id),
 0105                    LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id),
 0106                })
 0107                .ToListAsync(cancellationToken)
 0108                .ConfigureAwait(false);
 109
 0110            var keeper = stats
 0111                .OrderByDescending(s => s.UserDataCount)
 0112                .ThenByDescending(s => s.LinkedCount)
 0113                .ThenBy(s => s.DateCreated)
 0114                .First();
 115
 0116            foreach (var dup in stats.Where(s => s.Id != keeper.Id))
 117            {
 0118                var keeperId = keeper.Id;
 0119                var dupId = dup.Id;
 120
 0121                await context.BaseItems
 0122                    .Where(b => b.ParentId == dupId)
 0123                    .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken)
 0124                    .ConfigureAwait(false);
 125
 0126                await context.BaseItems
 0127                    .Where(b => b.OwnerId == dupId)
 0128                    .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken)
 0129                    .ConfigureAwait(false);
 130
 0131                await context.AncestorIds
 0132                    .Where(a => a.ParentItemId == dupId
 0133                        && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId))
 0134                    .ExecuteDeleteAsync(cancellationToken)
 0135                    .ConfigureAwait(false);
 0136                await context.AncestorIds
 0137                    .Where(a => a.ParentItemId == dupId)
 0138                    .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken)
 0139                    .ConfigureAwait(false);
 140
 0141                await context.LinkedChildren
 0142                    .Where(l => l.ParentId == dupId
 0143                        && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId))
 0144                    .ExecuteDeleteAsync(cancellationToken)
 0145                    .ConfigureAwait(false);
 0146                await context.LinkedChildren
 0147                    .Where(l => l.ParentId == dupId)
 0148                    .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken)
 0149                    .ConfigureAwait(false);
 0150                await context.LinkedChildren
 0151                    .Where(l => l.ChildId == dupId
 0152                        && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId))
 0153                    .ExecuteDeleteAsync(cancellationToken)
 0154                    .ConfigureAwait(false);
 0155                await context.LinkedChildren
 0156                    .Where(l => l.ChildId == dupId)
 0157                    .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken)
 0158                    .ConfigureAwait(false);
 159
 0160                await context.UserData
 0161                    .Where(u => u.ItemId == dupId
 0162                        && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == 
 0163                    .ExecuteDeleteAsync(cancellationToken)
 0164                    .ConfigureAwait(false);
 0165                await context.UserData
 0166                    .Where(u => u.ItemId == dupId)
 0167                    .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken)
 0168                    .ConfigureAwait(false);
 169
 0170                idsToDelete.Add(dupId);
 0171            }
 172
 0173            _logger.LogDebug(
 0174                "Merged Person BaseItems for '{Name}' into {KeeperId} ({Removed} removed).",
 0175                keeper.Name,
 0176                keeper.Id,
 0177                stats.Count - 1);
 0178        }
 179
 0180        if (idsToDelete.Count == 0)
 181        {
 0182            return;
 183        }
 184
 185        // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
 186        // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
 187        // Delete in batches so we never issue one massive delete transaction and progress stays visible.
 0188        _logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count);
 189        const int deleteBatchSize = 500;
 0190        var deletedSoFar = 0;
 0191        for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
 192        {
 0193            cancellationToken.ThrowIfCancellationRequested();
 194
 0195            var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
 196
 0197            var itemsToDelete = batchIds
 0198                .Select(id => _libraryManager.GetItemById(id))
 0199                .Where(item => item is not null)
 0200                .ToList();
 0201            if (itemsToDelete.Count > 0)
 202            {
 0203                _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
 204            }
 205
 0206            var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
 0207            var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
 0208            if (unresolvedIds.Count > 0)
 209            {
 0210                _persistenceService.DeleteItem(unresolvedIds);
 211            }
 212
 0213            deletedSoFar += batchIds.Count;
 0214            _logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.C
 215        }
 0216    }
 217
 218    private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
 219    {
 0220        var people = await context.Peoples
 0221            .Select(p => new { p.Id, p.Name, p.PersonType })
 0222            .ToListAsync(cancellationToken)
 0223            .ConfigureAwait(false);
 224
 0225        var groups = people
 0226            .GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType))
 0227            .Where(g => g.Count() > 1)
 0228            .ToList();
 229
 0230        if (groups.Count == 0)
 231        {
 0232            _logger.LogInformation("No case-only duplicate Peoples rows found.");
 0233            return;
 234        }
 235
 0236        _logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count);
 237
 0238        var idsToDelete = new List<Guid>();
 0239        foreach (var group in groups)
 240        {
 0241            cancellationToken.ThrowIfCancellationRequested();
 242
 0243            var groupIds = group.Select(g => g.Id).ToArray();
 244
 245            // Pick the keeper: the row referenced by the most BaseItems is the one most
 246            // tracks/movies already point at; the duplicates are usually orphan stubs left
 247            // by a casing-mismatched insert.
 0248            var stats = await context.Peoples
 0249                .Where(p => groupIds.Contains(p.Id))
 0250                .Select(p => new
 0251                {
 0252                    p.Id,
 0253                    p.Name,
 0254                    MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id),
 0255                })
 0256                .ToListAsync(cancellationToken)
 0257                .ConfigureAwait(false);
 258
 0259            var keeper = stats
 0260                .OrderByDescending(s => s.MapCount)
 0261                .ThenBy(s => s.Id)
 0262                .First();
 263
 0264            foreach (var dup in stats.Where(s => s.Id != keeper.Id))
 265            {
 0266                var keeperId = keeper.Id;
 0267                var dupId = dup.Id;
 268
 269                // PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would
 270                // collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so
 271                // match nulls explicitly.
 0272                await context.PeopleBaseItemMap
 0273                    .Where(m => m.PeopleId == dupId
 0274                        && context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId
 0275                            && k.ItemId == m.ItemId
 0276                            && (k.Role == m.Role || (k.Role == null && m.Role == null))))
 0277                    .ExecuteDeleteAsync(cancellationToken)
 0278                    .ConfigureAwait(false);
 0279                await context.PeopleBaseItemMap
 0280                    .Where(m => m.PeopleId == dupId)
 0281                    .ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken)
 0282                    .ConfigureAwait(false);
 283
 0284                idsToDelete.Add(dupId);
 0285            }
 286
 0287            _logger.LogDebug(
 0288                "Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).",
 0289                keeper.Name,
 0290                keeper.Id,
 0291                stats.Count - 1);
 0292        }
 293
 0294        if (idsToDelete.Count == 0)
 295        {
 0296            return;
 297        }
 298
 0299        var idx = 0;
 0300        foreach (var item in idsToDelete.Chunk(200))
 301        {
 0302            idx++; // humans count at one
 0303            _logger.LogInformation("Remove batch {BatchNo}/{MaxBatches} duplicate Peoples.", idx, idsToDelete.Count / 20
 0304            await context.Peoples
 0305                .Where(p => item.Contains(p.Id))
 0306                .ExecuteDeleteAsync(cancellationToken)
 0307                .ConfigureAwait(false);
 308        }
 309
 0310        _logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count);
 0311    }
 312}