diff options
5 files changed, 517 insertions, 13 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 0abe981af8..59e61cfd65 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -390,7 +390,8 @@ public sealed partial class BaseItemRepository { if (filter.UseRawName == true) { - baseQuery = baseQuery.Where(e => e.Name == filter.Name); + var nameLower = filter.Name.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower); } else { diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 415510b2f4..9e11b6be62 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -1,4 +1,6 @@ #pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1311 // Specify a culture or use an invariant version using System; using System.Collections.Generic; @@ -62,17 +64,19 @@ public class LinkedChildrenService : ILinkedChildrenService { using var dbContext = _dbProvider.CreateDbContext(); + var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray(); var artists = dbContext.BaseItems .AsNoTracking() .Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) - .Where(e => artistNames.Contains(e.Name)) + .Where(e => lowerNames.Contains(e.Name!.ToLower())) .ToArray(); var lookup = artists - .GroupBy(e => e.Name!) + .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); + g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(), + StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, MusicArtist[]>(artistNames.Count); foreach (var name in artistNames) diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 6cc9729bbe..a0ffe9aea0 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -46,9 +46,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { // The Peoples table has one row per (Name, PersonType), so the same person can // appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per - // name so /Persons doesn't return the same BaseItem id repeatedly. + // name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the + // grouping key so case-only duplicates collapse together. var representativeIds = dbQuery - .GroupBy(e => e.Name) + .GroupBy(e => e.Name.ToLower()) .Select(g => g.Min(e => e.Id)); dbQuery = context.Peoples.AsNoTracking() .Where(p => representativeIds.Contains(p.Id)) @@ -102,16 +103,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I person.Role = person.Role?.Trim() ?? string.Empty; } - // multiple metadata providers can provide the _same_ person - people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray(); - var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray(); + // multiple metadata providers can provide the _same_ person; dedupe case-insensitively. + people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); + var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); var existingPersons = context.Peoples.Select(e => new { item = e, - SelectionKey = e.Name + "-" + e.PersonType + SelectionKey = e.Name.ToLower() + "-" + e.PersonType }) .Where(p => personKeys.Contains(p.SelectionKey)) .Select(f => f.item) @@ -119,7 +120,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I var toAdd = people .Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist) - .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString())) + .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString())) .Select(Map); context.Peoples.AddRange(toAdd); context.SaveChanges(); @@ -137,8 +138,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I continue; } - var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString()); - var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); + var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString()); + var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); if (existingMap is null) { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() diff --git a/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs new file mode 100644 index 0000000000..f598848465 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs @@ -0,0 +1,204 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges MusicArtist records that differ only by Name casing. Prior to the case-insensitive +/// dedup lookup added alongside this migration, the artist validator would create a second +/// MusicArtist whenever a track tagged the artist with a different casing than the +/// resolver-created one (e.g. "Thirty Seconds To Mars" vs. "Thirty Seconds to Mars"). +/// </summary> +[JellyfinMigration("2026-05-08T12:00:00", nameof(MergeDuplicateMusicArtists))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine +{ + private const string MusicArtistType = "MediaBrowser.Controller.Entities.Audio.MusicArtist"; + + private readonly IStartupLogger<MergeDuplicateMusicArtists> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicateMusicArtists"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicateMusicArtists( + IStartupLogger<MergeDuplicateMusicArtists> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var artists = await context.BaseItems + .Where(b => b.Type == MusicArtistType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = artists + .GroupBy(a => a.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate MusicArtist records found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate MusicArtist records.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the artist with the most child references is the "real" one + // (the resolver-created artist with a filesystem path); the duplicates are usually + // empty stubs created by the validator's case-sensitive miss. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + ChildCount = context.BaseItems.Count(c => c.ParentId == b.Id), + AncestorCount = context.AncestorIds.Count(a => a.ParentItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.ChildCount) + .ThenByDescending(s => s.AncestorCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // AncestorIds PK is (ItemId, ParentItemId); drop rows that would collide before redirecting. + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // LinkedChildren PK is (ParentId, ChildId); drop colliding rows in both directions. + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // UserData has UNIQUE(UserId, CustomDataKey); keep the dup's row only when the + // keeper has no equivalent row, otherwise the keeper's value wins. + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged duplicates for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the + // %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind. + // Fall back to the persistence service for any items the LibraryManager can't resolve. + var itemsToDelete = idsToDelete + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + _logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs new file mode 100644 index 0000000000..d092555139 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs @@ -0,0 +1,294 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges case-only duplicate people. Two passes: +/// 1) Person BaseItems whose Name differs only by casing — Person.GetPath hashes the name +/// verbatim, so two casings produce two distinct Person rows in BaseItems. +/// 2) Peoples lookup rows whose Name differs only by casing within the same PersonType — +/// UpdatePeople used to insert a second Peoples row when a metadata provider returned +/// a different casing than the row already in the table. +/// Both bugs cause the /Persons endpoint to list the same person twice. +/// </summary> +[JellyfinMigration("2026-05-08T13:00:00", nameof(MergeDuplicatePeople))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicatePeople : IAsyncMigrationRoutine +{ + private const string PersonType = "MediaBrowser.Controller.Entities.Person"; + + private readonly IStartupLogger<MergeDuplicatePeople> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicatePeople"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicatePeople( + IStartupLogger<MergeDuplicatePeople> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await MergePersonBaseItemsAsync(context, cancellationToken).ConfigureAwait(false); + await MergePeoplesRowsAsync(context, cancellationToken).ConfigureAwait(false); + } + } + + private async Task MergePersonBaseItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var persons = await context.BaseItems + .Where(b => b.Type == PersonType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = persons + .GroupBy(p => p.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Person BaseItems found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Person BaseItems.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the Person with the most UserData rows (favorites, image + // refresh state) is the one users have actually interacted with. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + UserDataCount = context.UserData.Count(u => u.ItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.UserDataCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Person BaseItems for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the + // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind. + var itemsToDelete = idsToDelete + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + _logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count); + } + + private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var people = await context.Peoples + .Select(p => new { p.Id, p.Name, p.PersonType }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = people + .GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType)) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Peoples rows found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the row referenced by the most BaseItems is the one most + // tracks/movies already point at; the duplicates are usually orphan stubs left + // by a casing-mismatched insert. + var stats = await context.Peoples + .Where(p => groupIds.Contains(p.Id)) + .Select(p => new + { + p.Id, + p.Name, + MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.MapCount) + .ThenBy(s => s.Id) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + // PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would + // collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so + // match nulls explicitly. + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId + && context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId + && k.ItemId == m.ItemId + && (k.Role == m.Role || (k.Role == null && m.Role == null)))) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + await context.Peoples + .Where(p => idsToDelete.Contains(p.Id)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + _logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count); + } +} |
