< 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: 177
Coverable lines: 177
Total lines: 300
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 32
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: 300 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: 300

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
PerformAsync()100%210%
MergePersonBaseItemsAsync()0%342180%
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.
 0187        var itemsToDelete = idsToDelete
 0188            .Select(id => _libraryManager.GetItemById(id))
 0189            .Where(item => item is not null)
 0190            .ToList();
 0191        if (itemsToDelete.Count > 0)
 192        {
 0193            _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
 194        }
 195
 0196        var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
 0197        var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
 0198        if (unresolvedIds.Count > 0)
 199        {
 0200            _persistenceService.DeleteItem(unresolvedIds);
 201        }
 202
 0203        _logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count);
 0204    }
 205
 206    private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
 207    {
 0208        var people = await context.Peoples
 0209            .Select(p => new { p.Id, p.Name, p.PersonType })
 0210            .ToListAsync(cancellationToken)
 0211            .ConfigureAwait(false);
 212
 0213        var groups = people
 0214            .GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType))
 0215            .Where(g => g.Count() > 1)
 0216            .ToList();
 217
 0218        if (groups.Count == 0)
 219        {
 0220            _logger.LogInformation("No case-only duplicate Peoples rows found.");
 0221            return;
 222        }
 223
 0224        _logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count);
 225
 0226        var idsToDelete = new List<Guid>();
 0227        foreach (var group in groups)
 228        {
 0229            cancellationToken.ThrowIfCancellationRequested();
 230
 0231            var groupIds = group.Select(g => g.Id).ToArray();
 232
 233            // Pick the keeper: the row referenced by the most BaseItems is the one most
 234            // tracks/movies already point at; the duplicates are usually orphan stubs left
 235            // by a casing-mismatched insert.
 0236            var stats = await context.Peoples
 0237                .Where(p => groupIds.Contains(p.Id))
 0238                .Select(p => new
 0239                {
 0240                    p.Id,
 0241                    p.Name,
 0242                    MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id),
 0243                })
 0244                .ToListAsync(cancellationToken)
 0245                .ConfigureAwait(false);
 246
 0247            var keeper = stats
 0248                .OrderByDescending(s => s.MapCount)
 0249                .ThenBy(s => s.Id)
 0250                .First();
 251
 0252            foreach (var dup in stats.Where(s => s.Id != keeper.Id))
 253            {
 0254                var keeperId = keeper.Id;
 0255                var dupId = dup.Id;
 256
 257                // PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would
 258                // collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so
 259                // match nulls explicitly.
 0260                await context.PeopleBaseItemMap
 0261                    .Where(m => m.PeopleId == dupId
 0262                        && context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId
 0263                            && k.ItemId == m.ItemId
 0264                            && (k.Role == m.Role || (k.Role == null && m.Role == null))))
 0265                    .ExecuteDeleteAsync(cancellationToken)
 0266                    .ConfigureAwait(false);
 0267                await context.PeopleBaseItemMap
 0268                    .Where(m => m.PeopleId == dupId)
 0269                    .ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken)
 0270                    .ConfigureAwait(false);
 271
 0272                idsToDelete.Add(dupId);
 0273            }
 274
 0275            _logger.LogDebug(
 0276                "Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).",
 0277                keeper.Name,
 0278                keeper.Id,
 0279                stats.Count - 1);
 0280        }
 281
 0282        if (idsToDelete.Count == 0)
 283        {
 0284            return;
 285        }
 286
 0287        var idx = 0;
 0288        foreach (var item in idsToDelete.Chunk(200))
 289        {
 0290            idx++; // humans count at one
 0291            _logger.LogInformation("Remove batch {BatchNo}/{MaxBatches} duplicate Peoples.", idx, idsToDelete.Count / 20
 0292            await context.Peoples
 0293                .Where(p => item.Contains(p.Id))
 0294                .ExecuteDeleteAsync(cancellationToken)
 0295                .ConfigureAwait(false);
 296        }
 297
 0298        _logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count);
 0299    }
 300}