aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Shadowghost@users.noreply.github.com>2026-10-05 19:18:41 -0400
committerCody Robibero <cody@robibe.ro>2026-10-05 19:18:41 -0400
commite7764cbbd9db5e20ce764121d550b732405100e3 (patch)
tree6a88640e294ef6499ee26eadadbce2c2551b0eab
parent2e5e042f22f30fa7f49339f6a7f51cbec32383c5 (diff)
Backport pull request #18001 from jellyfin/release-12.z
Keep an item's user data rows in agreement Original-merge: fc98019b12e311fea244dccef72d521278326024 Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs16
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs73
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs131
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs159
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs40
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs138
6 files changed, 549 insertions, 8 deletions
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 0680046c11..1e7068b24b 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -75,6 +75,22 @@ namespace Emby.Server.Implementations.Library
}
dbContext.SaveChanges();
+
+ // Rows under keys the item no longer reports are kept, not deleted: the key set is briefly
+ // incomplete mid-refresh, and those rows are what a deleted and re-added item reattaches by.
+ dbContext.UserData
+ .Where(e => e.ItemId == item.Id && e.UserId == user.Id && !keys.Contains(e.CustomDataKey))
+ .ExecuteUpdate(e => e
+ .SetProperty(f => f.AudioStreamIndex, userData.AudioStreamIndex)
+ .SetProperty(f => f.IsFavorite, userData.IsFavorite)
+ .SetProperty(f => f.LastPlayedDate, userData.LastPlayedDate)
+ .SetProperty(f => f.Likes, userData.Likes)
+ .SetProperty(f => f.PlaybackPositionTicks, userData.PlaybackPositionTicks)
+ .SetProperty(f => f.PlayCount, userData.PlayCount)
+ .SetProperty(f => f.Played, userData.Played)
+ .SetProperty(f => f.Rating, userData.Rating)
+ .SetProperty(f => f.SubtitleStreamIndex, userData.SubtitleStreamIndex));
+
transaction.Commit();
var userId = user.InternalId;
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index 8b79e44680..3bf33b969e 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -230,17 +230,18 @@ public class ItemPersistenceService : IItemPersistenceService
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
- var userKeys = item.GetUserDataKeys().ToArray();
- var retentionDate = (DateTime?)null;
+ var userKeys = item.GetUserDataKeys().Distinct().ToList();
- await dbContext.UserData
+ var detached = await dbContext.UserData
.Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
- .ExecuteUpdateAsync(
- e => e
- .SetProperty(f => f.ItemId, item.Id)
- .SetProperty(f => f.RetentionDate, retentionDate),
- cancellationToken).ConfigureAwait(false);
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (detached.Count > 0)
+ {
+ await ReconcileUserDataAsync(dbContext, item, userKeys, detached, cancellationToken).ConfigureAwait(false);
+ }
item.UserData = await dbContext.UserData
.AsNoTracking()
@@ -253,6 +254,62 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ private static async Task ReconcileUserDataAsync(
+ JellyfinDbContext dbContext,
+ BaseItemDto item,
+ IReadOnlyList<string> userKeys,
+ List<UserData> detached,
+ CancellationToken cancellationToken)
+ {
+ var existing = await dbContext.UserData
+ .Where(e => e.ItemId == item.Id)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ // Keys the item no longer reports are carried over: they may only be missing mid-refresh.
+ var winners = detached.Concat(existing)
+ .GroupBy(e => e.UserId)
+ .Select(g => (
+ Winner: g
+ .OrderByDescending(e => e.LastPlayedDate)
+ .ThenByDescending(e => e.PlayCount)
+ .ThenByDescending(e => e.PlaybackPositionTicks)
+ .First(),
+ Keys: userKeys.Union(g.Select(e => e.CustomDataKey)).ToList()))
+ .ToList();
+
+ dbContext.UserData.RemoveRange(detached);
+ dbContext.UserData.RemoveRange(existing);
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var (winner, keys) in winners)
+ {
+ foreach (var key in keys)
+ {
+ dbContext.UserData.Add(new UserData
+ {
+ ItemId = item.Id,
+ Item = null,
+ UserId = winner.UserId,
+ User = null,
+ CustomDataKey = key,
+ RetentionDate = null,
+ AudioStreamIndex = winner.AudioStreamIndex,
+ IsFavorite = winner.IsFavorite,
+ LastPlayedDate = winner.LastPlayedDate,
+ Likes = winner.Likes,
+ PlaybackPositionTicks = winner.PlaybackPositionTicks,
+ PlayCount = winner.PlayCount,
+ Played = winner.Played,
+ Rating = winner.Rating,
+ SubtitleStreamIndex = winner.SubtitleStreamIndex
+ });
+ }
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(items);
diff --git a/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs b/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs
new file mode 100644
index 0000000000..2824c3234f
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260912120000_HarmonizeConflictingUserData.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Collapses conflicting user data rows so every row an item holds for a user says the same thing.
+/// </summary>
+[JellyfinMigration("2026-09-12T12:00:00", nameof(HarmonizeConflictingUserData))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class HarmonizeConflictingUserData : IAsyncMigrationRoutine
+{
+ private const int BatchSize = 500;
+
+ private readonly IStartupLogger<HarmonizeConflictingUserData> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="HarmonizeConflictingUserData"/> class.
+ /// </summary>
+ /// <param name="logger">The startup logger.</param>
+ /// <param name="dbContextFactory">The database context factory.</param>
+ public HarmonizeConflictingUserData(
+ IStartupLogger<HarmonizeConflictingUserData> logger,
+ IDbContextFactory<JellyfinDbContext> dbContextFactory)
+ {
+ _logger = logger;
+ _dbContextFactory = dbContextFactory;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ var conflicts = await context.UserData
+ .Where(e => !e.ItemId.Equals(BaseItemRepository.PlaceholderId))
+ .GroupBy(e => new { e.ItemId, e.UserId })
+ .Where(g => g.Count() > 1
+ && (g.Min(e => e.PlaybackPositionTicks) != g.Max(e => e.PlaybackPositionTicks)
+ || g.Min(e => e.PlayCount) != g.Max(e => e.PlayCount)
+ || g.Min(e => e.Played ? 1 : 0) != g.Max(e => e.Played ? 1 : 0)
+ || g.Min(e => e.IsFavorite ? 1 : 0) != g.Max(e => e.IsFavorite ? 1 : 0)
+ || g.Min(e => e.LastPlayedDate) != g.Max(e => e.LastPlayedDate)))
+ .Select(g => new { g.Key.ItemId, g.Key.UserId })
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (conflicts.Count == 0)
+ {
+ _logger.LogInformation("No conflicting user data found.");
+ return;
+ }
+
+ var conflictKeys = conflicts.ConvertAll(e => new ConflictKey(e.ItemId, e.UserId));
+
+ _logger.LogInformation("Harmonizing user data for {Count} item/user combinations.", conflictKeys.Count);
+
+ var harmonized = 0;
+ foreach (var batch in conflictKeys.Chunk(BatchSize))
+ {
+ harmonized += await HarmonizeBatchAsync(context, batch, cancellationToken).ConfigureAwait(false);
+ }
+
+ _logger.LogInformation("Updated {Count} user data rows.", harmonized);
+ }
+ }
+
+ private static async Task<int> HarmonizeBatchAsync(JellyfinDbContext context, ConflictKey[] batch, CancellationToken cancellationToken)
+ {
+ var itemIds = batch.Select(e => e.ItemId).Distinct().ToArray();
+ var wanted = batch.ToHashSet();
+
+ var rows = await context.UserData
+ .WhereOneOrMany(itemIds, e => e.ItemId)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var updated = 0;
+ foreach (var group in rows.GroupBy(e => new ConflictKey(e.ItemId, e.UserId)))
+ {
+ if (!wanted.Contains(group.Key))
+ {
+ continue;
+ }
+
+ // The most recent play is the state the user last produced; the others are fossils of
+ // earlier incarnations of the same item.
+ var winner = group
+ .OrderByDescending(e => e.LastPlayedDate)
+ .ThenByDescending(e => e.PlayCount)
+ .ThenByDescending(e => e.PlaybackPositionTicks)
+ .First();
+
+ foreach (var row in group)
+ {
+ if (ReferenceEquals(row, winner))
+ {
+ continue;
+ }
+
+ row.AudioStreamIndex = winner.AudioStreamIndex;
+ row.IsFavorite = winner.IsFavorite;
+ row.LastPlayedDate = winner.LastPlayedDate;
+ row.Likes = winner.Likes;
+ row.PlaybackPositionTicks = winner.PlaybackPositionTicks;
+ row.PlayCount = winner.PlayCount;
+ row.Played = winner.Played;
+ row.Rating = winner.Rating;
+ row.SubtitleStreamIndex = winner.SubtitleStreamIndex;
+ updated++;
+ }
+ }
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ context.ChangeTracker.Clear();
+
+ return updated;
+ }
+
+ private readonly record struct ConflictKey(Guid ItemId, Guid UserId);
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
index 82614c3156..fea6ccb7a1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
@@ -2,12 +2,16 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging.Abstractions;
@@ -21,12 +25,15 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
private readonly ItemPersistenceService _service;
private readonly ILibraryManager? _previousLibraryManager;
private readonly IServerConfigurationManager? _previousConfigurationManager;
+ private readonly IRecordingsManager? _previousRecordingsManager;
+ private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111");
public ItemPersistenceOwnedRowTests()
{
// BaseItem resolves these through process-wide statics; restored in Dispose.
_previousLibraryManager = BaseItem.LibraryManager;
_previousConfigurationManager = BaseItem.ConfigurationManager;
+ _previousRecordingsManager = Video.RecordingsManager;
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>()))
@@ -37,6 +44,9 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
BaseItem.ConfigurationManager = configurationManager.Object;
+ // Video.SourceType consults this before it can produce user data keys.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
_service = new ItemPersistenceService(
CreateDbContextFactory(),
new Mock<IServerApplicationHost>().Object,
@@ -47,6 +57,7 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
{
BaseItem.LibraryManager = _previousLibraryManager!;
BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ Video.RecordingsManager = _previousRecordingsManager!;
base.Dispose(disposing);
}
@@ -102,6 +113,99 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue);
}
+ [Fact]
+ public async Task ReattachUserData_DetachedRowsFromDifferentEras_CollapsesToMostRecentPlay()
+ {
+ var movie = CreateMovie(Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"));
+ var keys = movie.GetUserDataKeys();
+ SeedUserDataItem(movie);
+
+ using (var ctx = CreateDbContext())
+ {
+ // The guid-keyed row was detached by an older deletion than the provider-keyed ones.
+ ctx.UserData.AddRange(
+ CreateDetachedRow(keys[^1], new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490),
+ CreateDetachedRow(keys[0], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true),
+ CreateDetachedRow(keys[1], new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true));
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList();
+
+ Assert.Equal(keys.Count, rows.Count);
+ Assert.Equal(keys.OrderBy(e => e, StringComparer.Ordinal), rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal));
+ Assert.All(rows, row =>
+ {
+ Assert.True(row.Played);
+ Assert.Equal(0, row.PlaybackPositionTicks);
+ Assert.Equal(9, row.PlayCount);
+ Assert.Null(row.RetentionDate);
+ });
+
+ Assert.Empty(ctx.UserData.Where(e => e.ItemId.Equals(BaseItemRepository.PlaceholderId)));
+ }
+ }
+
+ [Fact]
+ public async Task ReattachUserData_ExistingRowUnderUnreportedKey_IsKeptInAgreement()
+ {
+ var movie = CreateMovie(Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"));
+ var keys = movie.GetUserDataKeys();
+ const string UnreportedKey = "tvdb-key-missing-mid-refresh";
+ SeedUserDataItem(movie);
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.UserData.AddRange(
+ CreateRow(movie.Id, UnreportedKey, new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123),
+ CreateDetachedRow(keys[0], new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 0, played: true));
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var rows = ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)).ToList();
+
+ Assert.Equal(
+ keys.Append(UnreportedKey).OrderBy(e => e, StringComparer.Ordinal),
+ rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal));
+ Assert.All(rows, row =>
+ {
+ Assert.True(row.Played);
+ Assert.Equal(2, row.PlayCount);
+ Assert.Equal(0, row.PlaybackPositionTicks);
+ });
+ }
+ }
+
+ [Fact]
+ public async Task ReattachUserData_NoDetachedRows_LeavesExistingRowsAlone()
+ {
+ var movie = CreateMovie(Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"));
+ var keys = movie.GetUserDataKeys();
+ SeedUserDataItem(movie);
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.UserData.Add(CreateRow(movie.Id, keys[0], new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 1, positionTicks: 123));
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await _service.ReattachUserDataAsync(movie, TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var row = Assert.Single(ctx.UserData.Where(e => e.ItemId.Equals(movie.Id)));
+ Assert.Equal(123, row.PlaybackPositionTicks);
+ }
+ }
+
private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields)
{
var book = new Book
@@ -115,4 +219,59 @@ public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture
book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
return book;
}
+
+ private static Movie CreateMovie(Guid id)
+ {
+ return new Movie
+ {
+ Id = id,
+ Name = "Black Widow",
+ ProviderIds = new Dictionary<string, string>
+ {
+ ["Tmdb"] = "497698",
+ ["Imdb"] = "tt3480822"
+ }
+ };
+ }
+
+ private void SeedUserDataItem(BaseItem item)
+ {
+ using var ctx = CreateDbContext();
+ if (!ctx.Users.Any(e => e.Id.Equals(_userId)))
+ {
+ ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId });
+ }
+
+ if (!ctx.BaseItems.Any(e => e.Id.Equals(BaseItemRepository.PlaceholderId)))
+ {
+ ctx.BaseItems.Add(new BaseItemEntity { Id = BaseItemRepository.PlaceholderId, Type = typeof(Folder).FullName! });
+ }
+
+ ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = item.GetType().FullName! });
+ ctx.SaveChanges();
+ }
+
+ private UserData CreateDetachedRow(string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false)
+ {
+ var row = CreateRow(BaseItemRepository.PlaceholderId, key, lastPlayed, playCount, positionTicks, played);
+ row.RetentionDate = new DateTime(2025, 6, 22, 0, 0, 0, DateTimeKind.Utc);
+
+ return row;
+ }
+
+ private UserData CreateRow(Guid itemId, string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false)
+ {
+ return new UserData
+ {
+ ItemId = itemId,
+ Item = null,
+ UserId = _userId,
+ User = null,
+ CustomDataKey = key,
+ LastPlayedDate = lastPlayed,
+ PlayCount = playCount,
+ PlaybackPositionTicks = positionTicks,
+ Played = played
+ };
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
index ba3127bc08..2ed16de15b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
@@ -8,6 +10,7 @@ using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
@@ -208,6 +211,43 @@ public sealed class UserDataManagerTests : IDisposable
}
[Fact]
+ public void SaveUserData_RowUnderRetiredKey_IsKeptInAgreement()
+ {
+ var item = CreateAudioBook();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(_user);
+ ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = typeof(AudioBook).FullName! });
+ ctx.UserData.Add(CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111));
+ ctx.SaveChanges();
+ }
+
+ _userDataManager.SaveUserData(
+ _user,
+ item,
+ new UserItemData { Key = item.GetUserDataKeys()[0], Played = true },
+ UserDataSaveReason.UpdateUserRating,
+ CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ var rows = ctx.UserData.Where(e => e.ItemId.Equals(item.Id)).ToList();
+
+ // The retired-key row is what a re-added item reattaches by, so it survives, but it must
+ // not keep a playback position that holds the item in Continue Watching.
+ Assert.Equal(
+ item.GetUserDataKeys().Append("Author-Old Album-0001Old File Name").OrderBy(e => e, StringComparer.Ordinal),
+ rows.Select(e => e.CustomDataKey).OrderBy(e => e, StringComparer.Ordinal));
+ Assert.All(rows, row =>
+ {
+ Assert.True(row.Played);
+ Assert.Equal(0, row.PlaybackPositionTicks);
+ });
+ }
+ }
+
+ [Fact]
public void GetUserData_NullUser_ThrowsArgumentNullException()
{
var item = CreateAudioBook();
diff --git a/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs b/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs
new file mode 100644
index 0000000000..ba0829cb56
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/HarmonizeConflictingUserDataTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.Migrations.Routines;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+public sealed class HarmonizeConflictingUserDataTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ private readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private readonly Guid _untouchedId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ public HarmonizeConflictingUserDataTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ public void Dispose() => _connection.Dispose();
+
+ [Fact]
+ public async Task Perform_ConflictingRows_CollapsesToMostRecentPlay()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId });
+ ctx.BaseItems.Add(new BaseItemEntity { Id = _itemId, Type = typeof(Folder).FullName! });
+ ctx.BaseItems.Add(new BaseItemEntity { Id = _untouchedId, Type = typeof(Folder).FullName! });
+
+ // One item holding rows from two different eras of itself...
+ ctx.UserData.Add(Row(_itemId, "497698", new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true));
+ ctx.UserData.Add(Row(_itemId, "tt3480822", new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490));
+
+ // ...and one whose rows already agree.
+ ctx.UserData.Add(Row(_untouchedId, "121", new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 77));
+ ctx.UserData.Add(Row(_untouchedId, "tt0167261", new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc), playCount: 2, positionTicks: 77));
+
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await CreateMigration().PerformAsync(TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var rows = ctx.UserData.Where(e => e.ItemId.Equals(_itemId)).ToList();
+ Assert.Equal(2, rows.Count);
+ Assert.All(rows, row =>
+ {
+ Assert.True(row.Played);
+ Assert.Equal(0, row.PlaybackPositionTicks);
+ Assert.Equal(9, row.PlayCount);
+ });
+
+ Assert.All(ctx.UserData.Where(e => e.ItemId.Equals(_untouchedId)), row => Assert.Equal(77, row.PlaybackPositionTicks));
+ }
+ }
+
+ [Fact]
+ public async Task Perform_DetachedRows_AreLeftAlone()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId });
+
+ // Detached rows belong to no item, so they are not a conflict and must keep their state
+ // for the next item that claims one of their keys.
+ ctx.UserData.Add(Row(BaseItemRepository.PlaceholderId, "497698", new DateTime(2023, 8, 14, 0, 0, 0, DateTimeKind.Utc), playCount: 9, positionTicks: 0, played: true));
+ ctx.UserData.Add(Row(BaseItemRepository.PlaceholderId, "tt3480822", new DateTime(2021, 12, 31, 0, 0, 0, DateTimeKind.Utc), playCount: 7, positionTicks: 490));
+
+ await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await CreateMigration().PerformAsync(TestContext.Current.CancellationToken);
+
+ using (var ctx = CreateDbContext())
+ {
+ var rows = ctx.UserData.Where(e => e.ItemId.Equals(BaseItemRepository.PlaceholderId)).OrderBy(e => e.CustomDataKey).ToList();
+ Assert.Equal(0, rows[0].PlaybackPositionTicks);
+ Assert.Equal(490, rows[1].PlaybackPositionTicks);
+ }
+ }
+
+ private UserData Row(Guid itemId, string key, DateTime lastPlayed, int playCount, long positionTicks, bool played = false)
+ {
+ return new UserData
+ {
+ ItemId = itemId,
+ Item = null,
+ UserId = _userId,
+ User = null,
+ CustomDataKey = key,
+ LastPlayedDate = lastPlayed,
+ PlayCount = playCount,
+ PlaybackPositionTicks = positionTicks,
+ Played = played
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(new Mock<IApplicationPaths>().Object, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+
+ private HarmonizeConflictingUserData CreateMigration()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
+
+ return new HarmonizeConflictingUserData(
+ new StartupLogger<HarmonizeConflictingUserData>(NullLogger<HarmonizeConflictingUserData>.Instance),
+ factory.Object);
+ }
+}