< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.MergeDuplicateMusicArtists
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 112
Coverable lines: 112
Total lines: 204
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 18
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/112) Branch coverage: 0% (0/18) Total lines: 204 5/11/2026 - 12:15:59 AM Line coverage: 0% (0/112) Branch coverage: 0% (0/18) Total lines: 204

Metrics

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

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.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 MusicArtist records that differ only by Name casing. Prior to the case-insensitive
 19/// dedup lookup added alongside this migration, the artist validator would create a second
 20/// MusicArtist whenever a track tagged the artist with a different casing than the
 21/// resolver-created one (e.g. "Thirty Seconds To Mars" vs. "Thirty Seconds to Mars").
 22/// </summary>
 23[JellyfinMigration("2026-05-08T12:00:00", nameof(MergeDuplicateMusicArtists))]
 24[JellyfinMigrationBackup(JellyfinDb = true)]
 25public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
 26{
 27    private const string MusicArtistType = "MediaBrowser.Controller.Entities.Audio.MusicArtist";
 28
 29    private readonly IStartupLogger<MergeDuplicateMusicArtists> _logger;
 30    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
 31    private readonly ILibraryManager _libraryManager;
 32    private readonly IItemPersistenceService _persistenceService;
 33
 34    /// <summary>
 35    /// Initializes a new instance of the <see cref="MergeDuplicateMusicArtists"/> class.
 36    /// </summary>
 37    /// <param name="logger">The startup logger.</param>
 38    /// <param name="dbContextFactory">The database context factory.</param>
 39    /// <param name="libraryManager">The library manager.</param>
 40    /// <param name="persistenceService">The item persistence service.</param>
 41    public MergeDuplicateMusicArtists(
 42        IStartupLogger<MergeDuplicateMusicArtists> logger,
 43        IDbContextFactory<JellyfinDbContext> dbContextFactory,
 44        ILibraryManager libraryManager,
 45        IItemPersistenceService persistenceService)
 46    {
 047        _logger = logger;
 048        _dbContextFactory = dbContextFactory;
 049        _libraryManager = libraryManager;
 050        _persistenceService = persistenceService;
 051    }
 52
 53    /// <inheritdoc/>
 54    public async Task PerformAsync(CancellationToken cancellationToken)
 55    {
 056        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 057        await using (context.ConfigureAwait(false))
 58        {
 059            var artists = await context.BaseItems
 060                .Where(b => b.Type == MusicArtistType && b.Name != null)
 061                .Select(b => new { b.Id, b.Name, b.DateCreated })
 062                .ToListAsync(cancellationToken)
 063                .ConfigureAwait(false);
 64
 065            var groups = artists
 066                .GroupBy(a => a.Name!.ToLowerInvariant())
 067                .Where(g => g.Count() > 1)
 068                .ToList();
 69
 070            if (groups.Count == 0)
 71            {
 072                _logger.LogInformation("No case-only duplicate MusicArtist records found.");
 073                return;
 74            }
 75
 076            _logger.LogInformation("Found {Count} groups of case-only duplicate MusicArtist records.", groups.Count);
 77
 078            var idsToDelete = new List<Guid>();
 079            foreach (var group in groups)
 80            {
 081                cancellationToken.ThrowIfCancellationRequested();
 82
 083                var groupIds = group.Select(g => g.Id).ToArray();
 84
 85                // Pick the keeper: the artist with the most child references is the "real" one
 86                // (the resolver-created artist with a filesystem path); the duplicates are usually
 87                // empty stubs created by the validator's case-sensitive miss.
 088                var stats = await context.BaseItems
 089                    .Where(b => groupIds.Contains(b.Id))
 090                    .Select(b => new
 091                    {
 092                        b.Id,
 093                        b.Name,
 094                        b.DateCreated,
 095                        ChildCount = context.BaseItems.Count(c => c.ParentId == b.Id),
 096                        AncestorCount = context.AncestorIds.Count(a => a.ParentItemId == b.Id),
 097                        LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id),
 098                    })
 099                    .ToListAsync(cancellationToken)
 0100                    .ConfigureAwait(false);
 101
 0102                var keeper = stats
 0103                    .OrderByDescending(s => s.ChildCount)
 0104                    .ThenByDescending(s => s.AncestorCount)
 0105                    .ThenByDescending(s => s.LinkedCount)
 0106                    .ThenBy(s => s.DateCreated)
 0107                    .First();
 108
 0109                foreach (var dup in stats.Where(s => s.Id != keeper.Id))
 110                {
 0111                    var keeperId = keeper.Id;
 0112                    var dupId = dup.Id;
 113
 0114                    await context.BaseItems
 0115                        .Where(b => b.ParentId == dupId)
 0116                        .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken)
 0117                        .ConfigureAwait(false);
 118
 0119                    await context.BaseItems
 0120                        .Where(b => b.OwnerId == dupId)
 0121                        .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken)
 0122                        .ConfigureAwait(false);
 123
 124                    // AncestorIds PK is (ItemId, ParentItemId); drop rows that would collide before redirecting.
 0125                    await context.AncestorIds
 0126                        .Where(a => a.ParentItemId == dupId
 0127                            && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId))
 0128                        .ExecuteDeleteAsync(cancellationToken)
 0129                        .ConfigureAwait(false);
 0130                    await context.AncestorIds
 0131                        .Where(a => a.ParentItemId == dupId)
 0132                        .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken)
 0133                        .ConfigureAwait(false);
 134
 135                    // LinkedChildren PK is (ParentId, ChildId); drop colliding rows in both directions.
 0136                    await context.LinkedChildren
 0137                        .Where(l => l.ParentId == dupId
 0138                            && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId))
 0139                        .ExecuteDeleteAsync(cancellationToken)
 0140                        .ConfigureAwait(false);
 0141                    await context.LinkedChildren
 0142                        .Where(l => l.ParentId == dupId)
 0143                        .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken)
 0144                        .ConfigureAwait(false);
 0145                    await context.LinkedChildren
 0146                        .Where(l => l.ChildId == dupId
 0147                            && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId))
 0148                        .ExecuteDeleteAsync(cancellationToken)
 0149                        .ConfigureAwait(false);
 0150                    await context.LinkedChildren
 0151                        .Where(l => l.ChildId == dupId)
 0152                        .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken)
 0153                        .ConfigureAwait(false);
 154
 155                    // UserData has UNIQUE(UserId, CustomDataKey); keep the dup's row only when the
 156                    // keeper has no equivalent row, otherwise the keeper's value wins.
 0157                    await context.UserData
 0158                        .Where(u => u.ItemId == dupId
 0159                            && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey
 0160                        .ExecuteDeleteAsync(cancellationToken)
 0161                        .ConfigureAwait(false);
 0162                    await context.UserData
 0163                        .Where(u => u.ItemId == dupId)
 0164                        .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken)
 0165                        .ConfigureAwait(false);
 166
 0167                    idsToDelete.Add(dupId);
 0168                }
 169
 0170                _logger.LogDebug(
 0171                    "Merged duplicates for '{Name}' into {KeeperId} ({Removed} removed).",
 0172                    keeper.Name,
 0173                    keeper.Id,
 0174                    stats.Count - 1);
 0175            }
 176
 0177            if (idsToDelete.Count == 0)
 178            {
 179                return;
 180            }
 181
 182            // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
 183            // %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
 184            // Fall back to the persistence service for any items the LibraryManager can't resolve.
 0185            var itemsToDelete = idsToDelete
 0186                .Select(id => _libraryManager.GetItemById(id))
 0187                .Where(item => item is not null)
 0188                .ToList();
 0189            if (itemsToDelete.Count > 0)
 190            {
 0191                _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
 192            }
 193
 0194            var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
 0195            var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
 0196            if (unresolvedIds.Count > 0)
 197            {
 0198                _persistenceService.DeleteItem(unresolvedIds);
 199            }
 200
 0201            _logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
 0202        }
 0203    }
 204}