< Summary - Jellyfin

Information
Class: Jellyfin.Server.Migrations.Routines.HarmonizeConflictingUserData
Assembly: jellyfin
File(s): /srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs
Line coverage
100%
Covered lines: 57
Uncovered lines: 0
Coverable lines: 57
Total lines: 131
Line coverage: 100%
Branch coverage
100%
Covered branches: 12
Total branches: 12
Branch coverage: 100%
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: 100% (57/57) Branch coverage: 100% (12/12) Total lines: 131 10/29/2026 - 12:17:11 AM Line coverage: 100% (57/57) Branch coverage: 100% (12/12) Total lines: 131

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
PerformAsync()100%44100%
HarmonizeBatchAsync()100%88100%

File(s)

/srv/git/jellyfin/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Threading;
 5using System.Threading.Tasks;
 6using Jellyfin.Database.Implementations;
 7using Jellyfin.Server.Implementations.Item;
 8using Jellyfin.Server.ServerSetupApp;
 9using Microsoft.EntityFrameworkCore;
 10using Microsoft.Extensions.Logging;
 11
 12namespace Jellyfin.Server.Migrations.Routines;
 13
 14/// <summary>
 15/// Collapses conflicting user data rows so every row an item holds for a user says the same thing.
 16/// </summary>
 17[JellyfinMigration("2026-09-12T12:00:00", nameof(HarmonizeConflictingUserData))]
 18[JellyfinMigrationBackup(JellyfinDb = true)]
 19public class HarmonizeConflictingUserData : IAsyncMigrationRoutine
 20{
 21    private const int BatchSize = 500;
 22
 23    private readonly IStartupLogger<HarmonizeConflictingUserData> _logger;
 24    private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
 25
 26    /// <summary>
 27    /// Initializes a new instance of the <see cref="HarmonizeConflictingUserData"/> class.
 28    /// </summary>
 29    /// <param name="logger">The startup logger.</param>
 30    /// <param name="dbContextFactory">The database context factory.</param>
 31    public HarmonizeConflictingUserData(
 32        IStartupLogger<HarmonizeConflictingUserData> logger,
 33        IDbContextFactory<JellyfinDbContext> dbContextFactory)
 34    {
 235        _logger = logger;
 236        _dbContextFactory = dbContextFactory;
 237    }
 38
 39    /// <inheritdoc/>
 40    public async Task PerformAsync(CancellationToken cancellationToken)
 41    {
 242        var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 243        await using (context.ConfigureAwait(false))
 44        {
 245            var conflicts = await context.UserData
 246                .Where(e => !e.ItemId.Equals(BaseItemRepository.PlaceholderId))
 247                .GroupBy(e => new { e.ItemId, e.UserId })
 248                .Where(g => g.Count() > 1
 249                    && (g.Min(e => e.PlaybackPositionTicks) != g.Max(e => e.PlaybackPositionTicks)
 250                        || g.Min(e => e.PlayCount) != g.Max(e => e.PlayCount)
 251                        || g.Min(e => e.Played ? 1 : 0) != g.Max(e => e.Played ? 1 : 0)
 252                        || g.Min(e => e.IsFavorite ? 1 : 0) != g.Max(e => e.IsFavorite ? 1 : 0)
 253                        || g.Min(e => e.LastPlayedDate) != g.Max(e => e.LastPlayedDate)))
 254                .Select(g => new { g.Key.ItemId, g.Key.UserId })
 255                .ToListAsync(cancellationToken)
 256                .ConfigureAwait(false);
 57
 258            if (conflicts.Count == 0)
 59            {
 160                _logger.LogInformation("No conflicting user data found.");
 161                return;
 62            }
 63
 164            var conflictKeys = conflicts.ConvertAll(e => new ConflictKey(e.ItemId, e.UserId));
 65
 166            _logger.LogInformation("Harmonizing user data for {Count} item/user combinations.", conflictKeys.Count);
 67
 168            var harmonized = 0;
 469            foreach (var batch in conflictKeys.Chunk(BatchSize))
 70            {
 171                harmonized += await HarmonizeBatchAsync(context, batch, cancellationToken).ConfigureAwait(false);
 72            }
 73
 174            _logger.LogInformation("Updated {Count} user data rows.", harmonized);
 75        }
 276    }
 77
 78    private static async Task<int> HarmonizeBatchAsync(JellyfinDbContext context, ConflictKey[] batch, CancellationToken
 79    {
 180        var itemIds = batch.Select(e => e.ItemId).Distinct().ToArray();
 181        var wanted = batch.ToHashSet();
 82
 183        var rows = await context.UserData
 184            .WhereOneOrMany(itemIds, e => e.ItemId)
 185            .ToListAsync(cancellationToken)
 186            .ConfigureAwait(false);
 87
 188        var updated = 0;
 489        foreach (var group in rows.GroupBy(e => new ConflictKey(e.ItemId, e.UserId)))
 90        {
 191            if (!wanted.Contains(group.Key))
 92            {
 93                continue;
 94            }
 95
 96            // The most recent play is the state the user last produced; the others are fossils of
 97            // earlier incarnations of the same item.
 198            var winner = group
 199                .OrderByDescending(e => e.LastPlayedDate)
 1100                .ThenByDescending(e => e.PlayCount)
 1101                .ThenByDescending(e => e.PlaybackPositionTicks)
 1102                .First();
 103
 6104            foreach (var row in group)
 105            {
 2106                if (ReferenceEquals(row, winner))
 107                {
 108                    continue;
 109                }
 110
 1111                row.AudioStreamIndex = winner.AudioStreamIndex;
 1112                row.IsFavorite = winner.IsFavorite;
 1113                row.LastPlayedDate = winner.LastPlayedDate;
 1114                row.Likes = winner.Likes;
 1115                row.PlaybackPositionTicks = winner.PlaybackPositionTicks;
 1116                row.PlayCount = winner.PlayCount;
 1117                row.Played = winner.Played;
 1118                row.Rating = winner.Rating;
 1119                row.SubtitleStreamIndex = winner.SubtitleStreamIndex;
 1120                updated++;
 121            }
 122        }
 123
 1124        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
 1125        context.ChangeTracker.Clear();
 126
 1127        return updated;
 1128    }
 129
 130    private readonly record struct ConflictKey(Guid ItemId, Guid UserId);
 131}