From fcce10894816768f275173eb6905c5aea402e515 Mon Sep 17 00:00:00 2001 From: Jordan Rushing Date: Mon, 13 Jul 2026 15:40:40 -0500 Subject: Fix: Fetch the correct row matching the most up to date file --- .../Library/UserDataManager.cs | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 40cd2bb69c..58126b97b2 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library } else { - var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault(); + var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + var userData = userDataRow is not null ? Map(userDataRow) : null; if (userData is not null) { result[item.Id] = userData; @@ -356,12 +357,40 @@ namespace Emby.Server.Implementations.Library /// public UserItemData? GetUserData(User user, BaseItem item) { - return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() + var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id))); + return row is not null ? Map(row) : new UserItemData() { Key = item.GetUserDataKeys()[0], }; } + /// + /// Picks the row matching the item's current user data keys, in key order, so rows left behind + /// under keys from older metadata don't take priority over the rows the write path updates. + /// + /// The item whose keys to match. + /// The candidate user data rows for a single user. + /// The best matching row, or null when there are none. + private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable? rows) + { + var candidates = rows?.ToList(); + if (candidates is null || candidates.Count == 0) + { + return null; + } + + foreach (var key in item.GetUserDataKeys()) + { + var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + } + + return candidates[0]; + } + /// public UserItemDataDto? GetUserDataDto(BaseItem item, User user) => GetUserDataDto(item, null, user, new DtoOptions()); -- cgit v1.2.3 From 3f96790904aa80cf070929f584c8d234e227236e Mon Sep 17 00:00:00 2001 From: Jordan Rushing Date: Wed, 15 Jul 2026 16:21:42 -0500 Subject: Move GetUserDataBatch to use ResolveUserDataRow when item.UserData isn't preloaded --- .../Library/UserDataManager.cs | 51 ++++++++-------- .../Library/UserDataManagerTests.cs | 68 ++++++++++++++++++++-- 2 files changed, 87 insertions(+), 32 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 58126b97b2..00f155e132 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -212,37 +212,32 @@ namespace Emby.Server.Implementations.Library return result; } - // Build a single query for all missing items + // Build a single query for all missing items. Fetch rows by item alone so rows kept + // under keys from older metadata resolve the same way as the in-memory path. var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList(); - var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList(); - if (allKeys.Count > 0) - { - using var context = _repository.CreateDbContext(); - var userDataArray = context.UserData - .AsNoTracking() - .Where(e => e.UserId.Equals(user.Id)) - .WhereOneOrMany(allItemIds, e => e.ItemId) - .WhereOneOrMany(allKeys, e => e.CustomDataKey) - .ToArray(); - - var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); - foreach (var (item, keys) in itemsNeedingQuery) + using var context = _repository.CreateDbContext(); + var userDataArray = context.UserData + .AsNoTracking() + .Where(e => e.UserId.Equals(user.Id)) + .WhereOneOrMany(allItemIds, e => e.ItemId) + .ToArray(); + + var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray()); + foreach (var (item, keys) in itemsNeedingQuery) + { + UserItemData userData; + if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) { - UserItemData userData; - if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0) - { - var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N")); - userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First()); - } - else - { - userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; - } - - result[item.Id] = userData; - var cacheKey = GetCacheKey(user.InternalId, item.Id); - _cache.AddOrUpdate(cacheKey, userData); + userData = Map(ResolveUserDataRow(item, itemUserData)!); } + else + { + userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty }; + } + + result[item.Id] = userData; + var cacheKey = GetCacheKey(user.InternalId, item.Id); + _cache.AddOrUpdate(cacheKey, userData); } return result; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs index 092e87b9ff..bd14ca008d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs @@ -3,35 +3,68 @@ using System.Collections.Generic; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; using AudioBook = MediaBrowser.Controller.Entities.AudioBook; namespace Jellyfin.Server.Implementations.Tests.Library; -public class UserDataManagerTests +public sealed class UserDataManagerTests : IDisposable { + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; private readonly UserDataManager _userDataManager; private readonly User _user; public UserDataManagerTests() { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + var config = new Mock(); config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration()); - var repository = Mock.Of>(); - - _userDataManager = new UserDataManager(config.Object, repository); + _userDataManager = new UserDataManager(config.Object, factory.Object); _user = new User("user", "auth-provider", "reset-provider") { Id = Guid.NewGuid() }; } + public void Dispose() + { + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(null!, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); + } + private AudioBook CreateAudioBook() { // GetUserDataKeys(): ["Author-Series-0001Book Title", ""] @@ -146,4 +179,31 @@ public class UserDataManagerTests Assert.NotNull(userData); Assert.Equal(222, userData.PlaybackPositionTicks); } + + [Fact] + public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder() + { + // no preloaded navigation data, so the batch takes the database fallback + var fossilItem = CreateAudioBook(); + var retiredItem = CreateAudioBook(); + + using (var ctx = CreateDbContext()) + { + ctx.Users.Add(_user); + ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! }); + ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! }); + + // the stale id-key row is inserted first so selection by row order would return it + ctx.UserData.AddRange( + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111), + CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222), + CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333)); + ctx.SaveChanges(); + } + + var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user); + + Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks); + Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks); + } } -- cgit v1.2.3